diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_overlay/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_overlay/index.tsx index ed8ae782946b4..91200fd57ceb7 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_overlay/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_overlay/index.tsx @@ -24,7 +24,7 @@ const StyledEuiModal = styled(EuiModal)` `; /** - * Modal container for Security Assistant conversations, receiving the page contents as context, plus whatever + * Modal container for Elastic AI Assistant conversations, receiving the page contents as context, plus whatever * component currently has focus and any specific context it may provide through the SAssInterface. */ export const AssistantOverlay: React.FC = React.memo(() => { diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/context_pills/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/context_pills/index.test.tsx index 75e3d4e015d45..88f90c2c0fa6b 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/context_pills/index.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/context_pills/index.test.tsx @@ -6,11 +6,11 @@ */ import React from 'react'; -import { fireEvent, render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { TestProviders } from '../../mock/test_providers/test_providers'; -import type { PromptContext } from '../prompt_context/types'; +import type { PromptContext, SelectedPromptContext } from '../prompt_context/types'; import { ContextPills } from '.'; const mockPromptContexts: Record = { @@ -30,6 +30,12 @@ const mockPromptContexts: Record = { }, }; +const defaultProps = { + defaultAllow: [], + defaultAllowReplacement: [], + promptContexts: mockPromptContexts, +}; + describe('ContextPills', () => { beforeEach(() => jest.clearAllMocks()); @@ -37,9 +43,9 @@ describe('ContextPills', () => { render( ); @@ -49,35 +55,45 @@ describe('ContextPills', () => { }); }); - it('invokes setSelectedPromptContextIds() when the prompt is NOT already selected', () => { + it('invokes setSelectedPromptContexts() when the prompt is NOT already selected', async () => { const context = mockPromptContexts.context1; - const setSelectedPromptContextIds = jest.fn(); + const setSelectedPromptContexts = jest.fn(); render( ); userEvent.click(screen.getByTestId(`pillButton-${context.id}`)); - expect(setSelectedPromptContextIds).toBeCalled(); + await waitFor(() => { + expect(setSelectedPromptContexts).toBeCalled(); + }); }); - it('it does NOT invoke setSelectedPromptContextIds() when the prompt is already selected', () => { + it('it does NOT invoke setSelectedPromptContexts() when the prompt is already selected', async () => { const context = mockPromptContexts.context1; - const setSelectedPromptContextIds = jest.fn(); + const mockSelectedPromptContext: SelectedPromptContext = { + allow: [], + allowReplacement: [], + promptContextId: context.id, + rawData: 'test-raw-data', + }; + const setSelectedPromptContexts = jest.fn(); render( ); @@ -85,18 +101,28 @@ describe('ContextPills', () => { // NOTE: this test uses `fireEvent` instead of `userEvent` to bypass the disabled button: fireEvent.click(screen.getByTestId(`pillButton-${context.id}`)); - expect(setSelectedPromptContextIds).not.toBeCalled(); + await waitFor(() => { + expect(setSelectedPromptContexts).not.toBeCalled(); + }); }); it('disables selected context pills', () => { const context = mockPromptContexts.context1; + const mockSelectedPromptContext: SelectedPromptContext = { + allow: [], + allowReplacement: [], + promptContextId: context.id, + rawData: 'test-raw-data', + }; render( ); @@ -110,9 +136,9 @@ describe('ContextPills', () => { render( ); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/context_pills/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/context_pills/index.tsx index a8522a75b5adb..be5374e37bd67 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/context_pills/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/context_pills/index.tsx @@ -11,22 +11,29 @@ import React, { useCallback, useMemo } from 'react'; // eslint-disable-next-line @kbn/eslint/module_migration import styled from 'styled-components'; -import type { PromptContext } from '../prompt_context/types'; +import { getNewSelectedPromptContext } from '../../data_anonymization/get_new_selected_prompt_context'; +import type { PromptContext, SelectedPromptContext } from '../prompt_context/types'; const PillButton = styled(EuiButton)` margin-right: ${({ theme }) => theme.eui.euiSizeXS}; `; interface Props { + defaultAllow: string[]; + defaultAllowReplacement: string[]; promptContexts: Record; - selectedPromptContextIds: string[]; - setSelectedPromptContextIds: React.Dispatch>; + selectedPromptContexts: Record; + setSelectedPromptContexts: React.Dispatch< + React.SetStateAction> + >; } const ContextPillsComponent: React.FC = ({ + defaultAllow, + defaultAllowReplacement, promptContexts, - selectedPromptContextIds, - setSelectedPromptContextIds, + selectedPromptContexts, + setSelectedPromptContexts, }) => { const sortedPromptContexts = useMemo( () => sortBy('description', Object.values(promptContexts)), @@ -34,12 +41,27 @@ const ContextPillsComponent: React.FC = ({ ); const selectPromptContext = useCallback( - (id: string) => { - if (!selectedPromptContextIds.includes(id)) { - setSelectedPromptContextIds((prev) => [...prev, id]); + async (id: string) => { + if (selectedPromptContexts[id] == null && promptContexts[id] != null) { + const newSelectedPromptContext = await getNewSelectedPromptContext({ + defaultAllow, + defaultAllowReplacement, + promptContext: promptContexts[id], + }); + + setSelectedPromptContexts((prev) => ({ + ...prev, + [id]: newSelectedPromptContext, + })); } }, - [selectedPromptContextIds, setSelectedPromptContextIds] + [ + defaultAllow, + defaultAllowReplacement, + promptContexts, + selectedPromptContexts, + setSelectedPromptContexts, + ] ); return ( @@ -49,7 +71,7 @@ const ContextPillsComponent: React.FC = ({ selectPromptContext(id)} diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/get_anonymized_value/index.test.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/get_anonymized_value/index.test.ts new file mode 100644 index 0000000000000..a3235c2c4012b --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/get_anonymized_value/index.test.ts @@ -0,0 +1,45 @@ +/* + * 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 { invert } from 'lodash/fp'; + +import { getAnonymizedValue } from '.'; + +jest.mock('uuid', () => ({ + v4: () => 'test-uuid', +})); + +describe('getAnonymizedValue', () => { + beforeEach(() => jest.clearAllMocks()); + + it('returns a new UUID when currentReplacements is not provided', () => { + const currentReplacements = undefined; + const rawValue = 'test'; + + const result = getAnonymizedValue({ currentReplacements, rawValue }); + + expect(result).toBe('test-uuid'); + }); + + it('returns an existing anonymized value when currentReplacements contains an entry for it', () => { + const rawValue = 'test'; + const currentReplacements = { anonymized: 'test' }; + const rawValueToReplacement = invert(currentReplacements); + + const result = getAnonymizedValue({ currentReplacements, rawValue }); + expect(result).toBe(rawValueToReplacement[rawValue]); + }); + + it('returns a new UUID with currentReplacements if no existing match', () => { + const rawValue = 'test'; + const currentReplacements = { anonymized: 'other' }; + + const result = getAnonymizedValue({ currentReplacements, rawValue }); + + expect(result).toBe('test-uuid'); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/get_anonymized_value/index.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/get_anonymized_value/index.ts new file mode 100644 index 0000000000000..455e9700882fb --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/get_anonymized_value/index.ts @@ -0,0 +1,26 @@ +/* + * 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 { invert } from 'lodash/fp'; +import { v4 } from 'uuid'; + +export const getAnonymizedValue = ({ + currentReplacements, + rawValue, +}: { + currentReplacements: Record | undefined; + rawValue: string; +}): string => { + if (currentReplacements != null) { + const rawValueToReplacement: Record = invert(currentReplacements); + const existingReplacement: string | undefined = rawValueToReplacement[rawValue]; + + return existingReplacement != null ? existingReplacement : v4(); + } + + return v4(); +}; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.tsx index 4c6d237e6cdfa..6ff45eadcaaa3 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.tsx @@ -15,6 +15,8 @@ import { EuiCommentList, EuiToolTip, EuiSplitPanel, + EuiSwitchEvent, + EuiSwitch, EuiCallOut, EuiIcon, EuiTitle, @@ -32,8 +34,10 @@ import { getMessageFromRawResponse } from './helpers'; import { ConversationSettingsPopover } from './conversation_settings_popover/conversation_settings_popover'; import { useAssistantContext } from '../assistant_context'; import { ContextPills } from './context_pills'; +import { getNewSelectedPromptContext } from '../data_anonymization/get_new_selected_prompt_context'; +import { SettingsPopover } from '../data_anonymization/settings/settings_popover'; import { PromptTextArea } from './prompt_textarea'; -import type { PromptContext } from './prompt_context/types'; +import type { PromptContext, SelectedPromptContext } from './prompt_context/types'; import { useConversation } from './use_conversation'; import { CodeBlockDetails } from './use_conversation/helpers'; import { useSendMessages } from './use_send_messages'; @@ -85,14 +89,23 @@ const AssistantComponent: React.FC = ({ actionTypeRegistry, augmentMessageCodeBlocks, conversations, + defaultAllow, + defaultAllowReplacement, getComments, http, promptContexts, title, } = useAssistantContext(); - const [selectedPromptContextIds, setSelectedPromptContextIds] = useState([]); + const [selectedPromptContexts, setSelectedPromptContexts] = useState< + Record + >({}); + const selectedPromptContextsCount = useMemo( + () => Object.keys(selectedPromptContexts).length, + [selectedPromptContexts] + ); - const { appendMessage, clearConversation, createConversation } = useConversation(); + const { appendMessage, appendReplacements, clearConversation, createConversation } = + useConversation(); const { isLoading, sendMessages } = useSendMessages(); const [selectedConversationId, setSelectedConversationId] = useState(conversationId); @@ -132,6 +145,8 @@ const AssistantComponent: React.FC = ({ const [showMissingConnectorCallout, setShowMissingConnectorCallout] = useState(false); + const [showAnonymizedValues, setShowAnonymizedValues] = useState(false); + const [messageCodeBlocks, setMessageCodeBlocks] = useState( augmentMessageCodeBlocks(currentConversation) ); @@ -179,17 +194,24 @@ const AssistantComponent: React.FC = ({ bottomRef.current?.scrollIntoView({ behavior: 'auto' }); promptTextAreaRef?.current?.focus(); }, 0); - }, [currentConversation.messages.length, selectedPromptContextIds.length]); + }, [currentConversation.messages.length, selectedPromptContextsCount]); //// // Handles sending latest user prompt to API const handleSendMessage = useCallback( async (promptText) => { + const onNewReplacements = (newReplacements: Record) => + appendReplacements({ + conversationId: selectedConversationId, + replacements: newReplacements, + }); + const message = await getCombinedMessage({ isNewChat: currentConversation.messages.length === 0, - promptContexts, + currentReplacements: currentConversation.replacements, + onNewReplacements, promptText, - selectedPromptContextIds, + selectedPromptContexts, selectedSystemPrompt: currentConversation.apiConfig.defaultSystemPrompt, }); @@ -199,7 +221,7 @@ const AssistantComponent: React.FC = ({ }); // Reset prompt context selection and preview before sending: - setSelectedPromptContextIds([]); + setSelectedPromptContexts({}); setPromptTextPreview(''); const rawResponse = await sendMessages({ @@ -212,12 +234,13 @@ const AssistantComponent: React.FC = ({ }, [ appendMessage, + appendReplacements, currentConversation.apiConfig, currentConversation.messages.length, + currentConversation.replacements, http, - promptContexts, selectedConversationId, - selectedPromptContextIds, + selectedPromptContexts, sendMessages, ] ); @@ -237,7 +260,24 @@ const AssistantComponent: React.FC = ({ codeBlockContainers.forEach((e) => (e.style.minHeight = '75px')); //// - const comments = getComments({ currentConversation, lastCommentRef }); + const onToggleShowAnonymizedValues = useCallback( + (e: EuiSwitchEvent) => { + if (setShowAnonymizedValues != null) { + setShowAnonymizedValues(e.target.checked); + } + }, + [setShowAnonymizedValues] + ); + + const comments = useMemo( + () => + getComments({ + currentConversation, + lastCommentRef, + showAnonymizedValues, + }), + [currentConversation, getComments, showAnonymizedValues] + ); useEffect(() => { // Adding `conversationId !== selectedConversationId` to prevent auto-run still executing after changing selected conversation @@ -253,12 +293,24 @@ const AssistantComponent: React.FC = ({ if (promptContext != null) { setAutoPopulatedOnce(true); - // select this prompt context - if (!selectedPromptContextIds.includes(promptContext.id)) { - setSelectedPromptContextIds((prev) => [...prev, promptContext.id]); + if (!Object.keys(selectedPromptContexts).includes(promptContext.id)) { + const addNewSelectedPromptContext = async () => { + const newSelectedPromptContext = await getNewSelectedPromptContext({ + defaultAllow, + defaultAllowReplacement, + promptContext, + }); + + setSelectedPromptContexts((prev) => ({ + ...prev, + [promptContext.id]: newSelectedPromptContext, + })); + }; + + addNewSelectedPromptContext(); } - if (promptContext?.suggestedUserPrompt != null) { + if (promptContext.suggestedUserPrompt != null) { setSuggestedUserPrompt(promptContext.suggestedUserPrompt); } } @@ -269,8 +321,10 @@ const AssistantComponent: React.FC = ({ handleSendMessage, conversationId, selectedConversationId, - selectedPromptContextIds, + selectedPromptContexts, autoPopulatedOnce, + defaultAllow, + defaultAllowReplacement, ]); // Show missing connector callout if no connectors are configured @@ -319,6 +373,35 @@ const AssistantComponent: React.FC = ({ shouldDisableKeyboardShortcut={shouldDisableConversationSelectorHotkeys} isDisabled={isWelcomeSetup} /> + + <> + + + + + 0 && + showAnonymizedValues + } + compressed={true} + disabled={currentConversation.replacements == null} + label={i18n.SHOW_ANONYMIZED} + onChange={onToggleShowAnonymizedValues} + /> + + + + + + + + @@ -354,9 +437,11 @@ const AssistantComponent: React.FC = ({ {!isWelcomeSetup && ( <> {Object.keys(promptContexts).length > 0 && } @@ -375,21 +460,22 @@ const AssistantComponent: React.FC = ({ <> -
{(currentConversation.messages.length === 0 || - selectedPromptContextIds.length > 0) && ( + Object.keys(selectedPromptContexts).length > 0) && ( )} + +
)} @@ -426,7 +512,7 @@ const AssistantComponent: React.FC = ({ onClick={() => { setPromptTextPreview(''); clearConversation(selectedConversationId); - setSelectedPromptContextIds([]); + setSelectedPromptContexts({}); setSuggestedUserPrompt(''); }} /> diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt/helpers.test.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt/helpers.test.ts index ca878bd5e5875..5604b21ad2e0d 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt/helpers.test.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt/helpers.test.ts @@ -7,10 +7,21 @@ import type { Message } from '../../assistant_context/types'; import { getCombinedMessage, getSystemMessages } from './helpers'; +import { mockGetAnonymizedValue } from '../../mock/get_anonymized_value'; import { mockSystemPrompt } from '../../mock/system_prompt'; -import { mockAlertPromptContext, mockEventPromptContext } from '../../mock/prompt_context'; +import { mockAlertPromptContext } from '../../mock/prompt_context'; +import type { SelectedPromptContext } from '../prompt_context/types'; + +const mockSelectedAlertPromptContext: SelectedPromptContext = { + allow: [], + allowReplacement: [], + promptContextId: mockAlertPromptContext.id, + rawData: 'alert data', +}; describe('helpers', () => { + beforeEach(() => jest.clearAllMocks()); + describe('getSystemMessages', () => { it('should return an empty array if isNewChat is false', () => { const result = getSystemMessages({ @@ -51,17 +62,15 @@ describe('helpers', () => { }); describe('getCombinedMessage', () => { - const mockPromptContexts = { - [mockAlertPromptContext.id]: mockAlertPromptContext, - [mockEventPromptContext.id]: mockEventPromptContext, - }; - it('returns correct content for a new chat with a system prompt', async () => { const message: Message = await getCombinedMessage({ + currentReplacements: {}, isNewChat: true, - promptContexts: mockPromptContexts, + onNewReplacements: jest.fn(), promptText: 'User prompt text', - selectedPromptContextIds: [mockAlertPromptContext.id], + selectedPromptContexts: { + [mockSelectedAlertPromptContext.promptContextId]: mockSelectedAlertPromptContext, + }, selectedSystemPrompt: mockSystemPrompt, }); @@ -78,10 +87,13 @@ User prompt text`); it('returns correct content for a new chat WITHOUT a system prompt', async () => { const message: Message = await getCombinedMessage({ + currentReplacements: {}, isNewChat: true, - promptContexts: mockPromptContexts, + onNewReplacements: jest.fn(), promptText: 'User prompt text', - selectedPromptContextIds: [mockAlertPromptContext.id], + selectedPromptContexts: { + [mockSelectedAlertPromptContext.promptContextId]: mockSelectedAlertPromptContext, + }, selectedSystemPrompt: undefined, // <-- no system prompt }); @@ -97,10 +109,13 @@ User prompt text`); it('returns the correct content for an existing chat', async () => { const message: Message = await getCombinedMessage({ + currentReplacements: {}, isNewChat: false, - promptContexts: mockPromptContexts, + onNewReplacements: jest.fn(), promptText: 'User prompt text', - selectedPromptContextIds: [mockAlertPromptContext.id], + selectedPromptContexts: { + [mockSelectedAlertPromptContext.promptContextId]: mockSelectedAlertPromptContext, + }, selectedSystemPrompt: mockSystemPrompt, }); @@ -109,36 +124,89 @@ User prompt text`); alert data """ -CONTEXT: -""" -alert data -""" - User prompt text`); }); - test('getCombinedMessage returns the expected role', async () => { + it('returns the expected role', async () => { const message: Message = await getCombinedMessage({ + currentReplacements: {}, isNewChat: true, - promptContexts: mockPromptContexts, + onNewReplacements: jest.fn(), promptText: 'User prompt text', - selectedPromptContextIds: [mockAlertPromptContext.id], + selectedPromptContexts: { + [mockSelectedAlertPromptContext.promptContextId]: mockSelectedAlertPromptContext, + }, selectedSystemPrompt: mockSystemPrompt, }); expect(message.role).toBe('user'); }); - test('getCombinedMessage returns a valid timestamp', async () => { + it('returns a valid timestamp', async () => { const message: Message = await getCombinedMessage({ + currentReplacements: {}, isNewChat: true, - promptContexts: mockPromptContexts, + onNewReplacements: jest.fn(), promptText: 'User prompt text', - selectedPromptContextIds: [mockAlertPromptContext.id], + selectedPromptContexts: {}, selectedSystemPrompt: mockSystemPrompt, }); expect(Date.parse(message.timestamp)).not.toBeNaN(); }); + + describe('when there is data to anonymize', () => { + const onNewReplacements = jest.fn(); + + const mockPromptContextWithDataToAnonymize: SelectedPromptContext = { + allow: ['field1', 'field2'], + allowReplacement: ['field1', 'field2'], + promptContextId: 'test-prompt-context-id', + rawData: { + field1: ['foo', 'bar', 'baz'], + field2: ['foozle'], + }, + }; + + it('invokes `onNewReplacements` with the expected replacements', async () => { + await getCombinedMessage({ + currentReplacements: {}, + getAnonymizedValue: mockGetAnonymizedValue, + isNewChat: true, + onNewReplacements, + promptText: 'User prompt text', + selectedPromptContexts: { + [mockPromptContextWithDataToAnonymize.promptContextId]: + mockPromptContextWithDataToAnonymize, + }, + selectedSystemPrompt: mockSystemPrompt, + }); + + expect(onNewReplacements).toBeCalledWith({ + elzoof: 'foozle', + oof: 'foo', + rab: 'bar', + zab: 'baz', + }); + }); + + it('returns the expected content when `isNewChat` is false', async () => { + const isNewChat = false; // <-- not a new chat + + const message: Message = await getCombinedMessage({ + currentReplacements: {}, + getAnonymizedValue: mockGetAnonymizedValue, + isNewChat, + onNewReplacements: jest.fn(), + promptText: 'User prompt text', + selectedPromptContexts: {}, + selectedSystemPrompt: mockSystemPrompt, + }); + + expect(message.content).toEqual(` + +User prompt text`); + }); + }); }); }); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt/helpers.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt/helpers.ts index 10dd5f62a2a89..cbf2259489505 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt/helpers.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt/helpers.ts @@ -7,7 +7,10 @@ import type { Message } from '../../assistant_context/types'; import { SYSTEM_PROMPT_CONTEXT_NON_I18N } from '../../content/prompts/system/translations'; -import type { PromptContext } from '../prompt_context/types'; + +import { transformRawData } from '../../data_anonymization/transform_raw_data'; +import { getAnonymizedValue as defaultGetAnonymizedValue } from '../get_anonymized_value'; +import type { SelectedPromptContext } from '../prompt_context/types'; import type { Prompt } from '../types'; export const getSystemMessages = ({ @@ -31,35 +34,45 @@ export const getSystemMessages = ({ }; export async function getCombinedMessage({ + currentReplacements, + getAnonymizedValue = defaultGetAnonymizedValue, isNewChat, - promptContexts, + onNewReplacements, promptText, - selectedPromptContextIds, + selectedPromptContexts, selectedSystemPrompt, }: { + currentReplacements: Record | undefined; + getAnonymizedValue?: ({ + currentReplacements, + rawValue, + }: { + currentReplacements: Record | undefined; + rawValue: string; + }) => string; isNewChat: boolean; - promptContexts: Record; + onNewReplacements: (newReplacements: Record) => void; promptText: string; - selectedPromptContextIds: string[]; + selectedPromptContexts: Record; selectedSystemPrompt: Prompt | undefined; }): Promise { - const selectedPromptContexts = selectedPromptContextIds.reduce((acc, id) => { - const promptContext = promptContexts[id]; - return promptContext != null ? [...acc, promptContext] : acc; - }, []); - - const promptContextsContent = await Promise.all( - selectedPromptContexts.map(async ({ getPromptContext }) => { - const promptContext = await getPromptContext(); + const promptContextsContent = Object.keys(selectedPromptContexts) + .sort() + .map((id) => { + const promptContext = transformRawData({ + currentReplacements, + getAnonymizedValue, + onNewReplacements, + selectedPromptContext: selectedPromptContexts[id], + }); return `${SYSTEM_PROMPT_CONTEXT_NON_I18N(promptContext)}`; - }) - ); + }); return { - content: `${isNewChat ? `${selectedSystemPrompt?.content ?? ''}` : `${promptContextsContent}`} - -${promptContextsContent} + content: `${ + isNewChat ? `${selectedSystemPrompt?.content ?? ''}\n\n` : '' + }${promptContextsContent} ${promptText}`, role: 'user', // we are combining the system and user messages into one message diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_context/types.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_context/types.ts index 9c9b807281665..4e821c5c44bfa 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_context/types.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_context/types.ts @@ -8,7 +8,7 @@ import type { ReactNode } from 'react'; /** - * helps the Elastic Assistant display the most relevant user prompts + * helps the Elastic AI Assistant display the most relevant user prompts */ export type PromptContextCategory = | 'alert' @@ -19,7 +19,7 @@ export type PromptContextCategory = | string; /** - * This interface is used to pass context to the Elastic Assistant, + * This interface is used to pass context to the Elastic AI Assistant, * for the purpose of building prompts. Examples of context include: * - a single alert * - multiple alerts @@ -33,39 +33,53 @@ export interface PromptContext { /** * The category of data, e.g. `alert | alerts | event | events | string` * - * `category` helps the Elastic Assistant display the most relevant user prompts + * `category` helps the Elastic AI Assistant display the most relevant user prompts */ category: PromptContextCategory; /** - * The Elastic Assistant will display this **short**, static description + * The Elastic AI Assistant will display this **short**, static description * in the context pill */ description: string; /** - * The Elastic Assistant will invoke this function to retrieve the context data, + * The Elastic AI Assistant will invoke this function to retrieve the context data, * which will be included in a prompt (e.g. the contents of an alert or an event) */ - getPromptContext: () => Promise; + getPromptContext: () => Promise | Promise>; /** * A unique identifier for this prompt context */ id: string; /** - * An optional user prompt that's filled in, but not sent, when the Elastic Assistant opens + * An optional user prompt that's filled in, but not sent, when the Elastic AI Assistant opens */ suggestedUserPrompt?: string; /** - * The Elastic Assistant will display this tooltip when the user hovers over the context pill + * The Elastic AI Assistant will display this tooltip when the user hovers over the context pill */ tooltip: ReactNode; } /** - * This interface is used to pass a default or base set of contexts to the Elastic Assistant when + * A prompt context that was added from the pills to the current conversation, but not yet sent + */ +export interface SelectedPromptContext { + /** fields allowed to be included in a conversation */ + allow: string[]; + /** fields that will be anonymized */ + allowReplacement: string[]; + /** unique id of the selected `PromptContext` */ + promptContextId: string; + /** this data is not anonymized */ + rawData: string | Record; +} + +/** + * This interface is used to pass a default or base set of contexts to the Elastic AI Assistant when * initializing it. This is used to provide 'category' options when users create Quick Prompts. * Also, useful for collating all of a solutions' prompts in one place. * diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/index.test.tsx index 8aa50c7f86224..b14e69160f9c3 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/index.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/index.test.tsx @@ -10,8 +10,23 @@ import { render, screen, waitFor } from '@testing-library/react'; import { mockAlertPromptContext, mockEventPromptContext } from '../../mock/prompt_context'; import { TestProviders } from '../../mock/test_providers/test_providers'; +import { SelectedPromptContext } from '../prompt_context/types'; import { PromptEditor, Props } from '.'; +const mockSelectedAlertPromptContext: SelectedPromptContext = { + allow: [], + allowReplacement: [], + promptContextId: mockAlertPromptContext.id, + rawData: 'alert data', +}; + +const mockSelectedEventPromptContext: SelectedPromptContext = { + allow: [], + allowReplacement: [], + promptContextId: mockEventPromptContext.id, + rawData: 'event data', +}; + const defaultProps: Props = { conversation: undefined, isNewConversation: true, @@ -20,8 +35,8 @@ const defaultProps: Props = { [mockEventPromptContext.id]: mockEventPromptContext, }, promptTextPreview: 'Preview text', - selectedPromptContextIds: [], - setSelectedPromptContextIds: jest.fn(), + selectedPromptContexts: {}, + setSelectedPromptContexts: jest.fn(), }; describe('PromptEditorComponent', () => { @@ -52,16 +67,19 @@ describe('PromptEditorComponent', () => { }); it('renders the selected prompt contexts', async () => { - const selectedPromptContextIds = [mockAlertPromptContext.id, mockEventPromptContext.id]; + const selectedPromptContexts = { + [mockAlertPromptContext.id]: mockSelectedAlertPromptContext, + [mockEventPromptContext.id]: mockSelectedEventPromptContext, + }; render( - + ); await waitFor(() => { - selectedPromptContextIds.forEach((id) => + Object.keys(selectedPromptContexts).forEach((id) => expect(screen.queryByTestId(`selectedPromptContext-${id}`)).toBeInTheDocument() ); }); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/index.tsx index 3de97f30593ca..a35259c0655a6 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/index.tsx @@ -11,7 +11,7 @@ import React, { useMemo } from 'react'; import styled from 'styled-components'; import { Conversation } from '../../..'; -import type { PromptContext } from '../prompt_context/types'; +import type { PromptContext, SelectedPromptContext } from '../prompt_context/types'; import { SystemPrompt } from './system_prompt'; import * as i18n from './translations'; @@ -22,8 +22,10 @@ export interface Props { isNewConversation: boolean; promptContexts: Record; promptTextPreview: string; - selectedPromptContextIds: string[]; - setSelectedPromptContextIds: React.Dispatch>; + selectedPromptContexts: Record; + setSelectedPromptContexts: React.Dispatch< + React.SetStateAction> + >; } const PreviewText = styled(EuiText)` @@ -35,8 +37,8 @@ const PromptEditorComponent: React.FC = ({ isNewConversation, promptContexts, promptTextPreview, - selectedPromptContextIds, - setSelectedPromptContextIds, + selectedPromptContexts, + setSelectedPromptContexts, }) => { const commentBody = useMemo( () => ( @@ -46,8 +48,8 @@ const PromptEditorComponent: React.FC = ({ @@ -60,8 +62,8 @@ const PromptEditorComponent: React.FC = ({ isNewConversation, promptContexts, promptTextPreview, - selectedPromptContextIds, - setSelectedPromptContextIds, + selectedPromptContexts, + setSelectedPromptContexts, ] ); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/selected_prompt_contexts/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/selected_prompt_contexts/index.test.tsx index 4ab4b708a68c8..8b71d45d3bc21 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/selected_prompt_contexts/index.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/selected_prompt_contexts/index.test.tsx @@ -11,6 +11,7 @@ import userEvent from '@testing-library/user-event'; import { mockAlertPromptContext, mockEventPromptContext } from '../../../mock/prompt_context'; import { TestProviders } from '../../../mock/test_providers/test_providers'; +import type { SelectedPromptContext } from '../../prompt_context/types'; import { Props, SelectedPromptContexts } from '.'; const defaultProps: Props = { @@ -19,8 +20,22 @@ const defaultProps: Props = { [mockAlertPromptContext.id]: mockAlertPromptContext, [mockEventPromptContext.id]: mockEventPromptContext, }, - selectedPromptContextIds: [], - setSelectedPromptContextIds: jest.fn(), + selectedPromptContexts: {}, + setSelectedPromptContexts: jest.fn(), +}; + +const mockSelectedAlertPromptContext: SelectedPromptContext = { + allow: [], + allowReplacement: [], + promptContextId: mockAlertPromptContext.id, + rawData: 'test-raw-data', +}; + +const mockSelectedEventPromptContext: SelectedPromptContext = { + allow: [], + allowReplacement: [], + promptContextId: mockEventPromptContext.id, + rawData: 'test-raw-data', }; describe('SelectedPromptContexts', () => { @@ -44,7 +59,9 @@ describe('SelectedPromptContexts', () => { ); @@ -60,7 +77,9 @@ describe('SelectedPromptContexts', () => { ); @@ -76,7 +95,10 @@ describe('SelectedPromptContexts', () => { ); @@ -87,57 +109,67 @@ describe('SelectedPromptContexts', () => { }); it('renders the selected prompt contexts', async () => { - const selectedPromptContextIds = [mockAlertPromptContext.id, mockEventPromptContext.id]; + const selectedPromptContexts = { + [mockAlertPromptContext.id]: mockSelectedAlertPromptContext, + [mockEventPromptContext.id]: mockSelectedEventPromptContext, + }; render( - + ); await waitFor(() => { - selectedPromptContextIds.forEach((id) => + Object.keys(selectedPromptContexts).forEach((id) => expect(screen.getByTestId(`selectedPromptContext-${id}`)).toBeInTheDocument() ); }); }); it('removes a prompt context when the remove button is clicked', async () => { - const setSelectedPromptContextIds = jest.fn(); + const setSelectedPromptContexts = jest.fn(); const promptContextId = mockAlertPromptContext.id; + const selectedPromptContexts = { + [mockAlertPromptContext.id]: mockSelectedAlertPromptContext, + [mockEventPromptContext.id]: mockSelectedEventPromptContext, + }; render( - + + + ); userEvent.click(screen.getByTestId(`removePromptContext-${promptContextId}`)); await waitFor(() => { - expect(setSelectedPromptContextIds).toHaveBeenCalled(); + expect(setSelectedPromptContexts).toHaveBeenCalled(); }); }); it('displays the correct accordion content', async () => { render( - + + + ); userEvent.click(screen.getByText(mockAlertPromptContext.description)); - const codeBlock = screen.getByTestId('promptCodeBlock'); + const codeBlock = screen.getByTestId('readOnlyContextViewer'); await waitFor(() => { - expect(codeBlock).toHaveTextContent('alert data'); + expect(codeBlock).toHaveTextContent('CONTEXT: """ test-raw-data """'); }); }); }); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/selected_prompt_contexts/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/selected_prompt_contexts/index.tsx index eca303284d1a8..b8aed6edf0212 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/selected_prompt_contexts/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/selected_prompt_contexts/index.tsx @@ -8,114 +8,98 @@ import { EuiAccordion, EuiButtonIcon, - EuiCodeBlock, EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiToolTip, } from '@elastic/eui'; -import { isEmpty } from 'lodash/fp'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { isEmpty, omit } from 'lodash/fp'; +import React, { useCallback } from 'react'; // eslint-disable-next-line @kbn/eslint/module_migration import styled from 'styled-components'; -import { SYSTEM_PROMPT_CONTEXT_NON_I18N } from '../../../content/prompts/system/translations'; -import type { PromptContext } from '../../prompt_context/types'; +import { DataAnonymizationEditor } from '../../../data_anonymization_editor'; +import type { PromptContext, SelectedPromptContext } from '../../prompt_context/types'; import * as i18n from './translations'; -const PromptContextContainer = styled.div` - max-width: 60vw; - overflow-x: auto; -`; - export interface Props { isNewConversation: boolean; promptContexts: Record; - selectedPromptContextIds: string[]; - setSelectedPromptContextIds: React.Dispatch>; + selectedPromptContexts: Record; + setSelectedPromptContexts: React.Dispatch< + React.SetStateAction> + >; } +export const EditorContainer = styled.div<{ + $accordionState: 'closed' | 'open'; +}>` + ${({ $accordionState }) => ($accordionState === 'closed' ? 'height: 0px;' : '')} + ${({ $accordionState }) => ($accordionState === 'closed' ? 'overflow: hidden;' : '')} + ${({ $accordionState }) => ($accordionState === 'closed' ? 'position: absolute;' : '')} +`; + const SelectedPromptContextsComponent: React.FC = ({ isNewConversation, promptContexts, - selectedPromptContextIds, - setSelectedPromptContextIds, + selectedPromptContexts, + setSelectedPromptContexts, }) => { - const selectedPromptContexts = useMemo( - () => selectedPromptContextIds.map((id) => promptContexts[id]), - [promptContexts, selectedPromptContextIds] - ); + const [accordionState, setAccordionState] = React.useState<'closed' | 'open'>('closed'); - const [accordionContent, setAccordionContent] = useState>({}); + const onToggle = useCallback( + () => setAccordionState((prev) => (prev === 'open' ? 'closed' : 'open')), + [] + ); const unselectPromptContext = useCallback( (unselectedId: string) => { - setSelectedPromptContextIds((prev) => prev.filter((id) => id !== unselectedId)); + setSelectedPromptContexts((prev) => omit(unselectedId, prev)); }, - [setSelectedPromptContextIds] + [setSelectedPromptContexts] ); - useEffect(() => { - const abortController = new AbortController(); - - const fetchAccordionContent = async () => { - const newAccordionContent = await Promise.all( - selectedPromptContexts.map(async ({ getPromptContext, id }) => ({ - [id]: await getPromptContext(), - })) - ); - - if (!abortController.signal.aborted) { - setAccordionContent(newAccordionContent.reduce((acc, curr) => ({ ...acc, ...curr }), {})); - } - }; - - fetchAccordionContent(); - - return () => { - abortController.abort(); - }; - }, [selectedPromptContexts]); - if (isEmpty(promptContexts)) { return null; } return ( - {selectedPromptContexts.map(({ description, id }) => ( - - {isNewConversation || selectedPromptContexts.length > 1 ? ( - - ) : null} - - unselectPromptContext(id)} + {Object.keys(selectedPromptContexts) + .sort() + .map((id) => ( + + {isNewConversation || Object.keys(selectedPromptContexts).length > 1 ? ( + + ) : null} + + unselectPromptContext(id)} + /> + + } + id={id} + onToggle={onToggle} + paddingSize="s" + > + + - - } - id={id} - paddingSize="s" - > - - - {id != null && accordionContent[id] != null - ? SYSTEM_PROMPT_CONTEXT_NON_I18N(accordionContent[id]) - : ''} - - - - - ))} + + + + ))} ); }; -SelectedPromptContextsComponent.displayName = 'SelectedPromptContextsComponent'; export const SelectedPromptContexts = React.memo(SelectedPromptContextsComponent); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/translations.ts index faf9a4d21c3c8..0b401eeb400a9 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/translations.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/translations.ts @@ -14,7 +14,7 @@ export const CLEAR_CHAT = i18n.translate('xpack.elasticAssistant.assistant.clear export const DEFAULT_ASSISTANT_TITLE = i18n.translate( 'xpack.elasticAssistant.assistant.defaultAssistantTitle', { - defaultMessage: 'Elastic Assistant', + defaultMessage: 'Elastic AI Assistant', } ); @@ -58,6 +58,20 @@ export const SETTINGS_PROMPT_HELP_TEXT_TITLE = i18n.translate( } ); +export const SHOW_ANONYMIZED = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.showAnonymizedToggleLabel', + { + defaultMessage: 'Show anonymized', + } +); + +export const SHOW_ANONYMIZED_TOOLTIP = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.showAnonymizedTooltip', + { + defaultMessage: 'Show the anonymized values sent to and from the assistant', + } +); + export const SUBMIT_MESSAGE = i18n.translate('xpack.elasticAssistant.assistant.submitMessage', { defaultMessage: 'Submit message', }); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_assistant_overlay/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_assistant_overlay/index.tsx index f804ec5178ca2..ba1992b5e50de 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_assistant_overlay/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_assistant_overlay/index.tsx @@ -58,7 +58,7 @@ export const useAssistantOverlay = ( id: PromptContext['id'] | null, /** - * An optional user prompt that's filled in, but not sent, when the Elastic Assistant opens + * An optional user prompt that's filled in, but not sent, when the Elastic AI Assistant opens */ suggestedUserPrompt: PromptContext['suggestedUserPrompt'] | null, diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_conversation/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_conversation/index.tsx index 7fa03f713ee8d..08124d750d949 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_conversation/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_conversation/index.tsx @@ -10,17 +10,17 @@ import { useCallback } from 'react'; import { useAssistantContext } from '../../assistant_context'; import { Conversation, Message } from '../../assistant_context/types'; import * as i18n from './translations'; -import { ELASTIC_SECURITY_ASSISTANT, ELASTIC_SECURITY_ASSISTANT_TITLE } from './translations'; +import { ELASTIC_AI_ASSISTANT, ELASTIC_AI_ASSISTANT_TITLE } from './translations'; export const DEFAULT_CONVERSATION_STATE: Conversation = { id: i18n.DEFAULT_CONVERSATION_TITLE, messages: [], apiConfig: {}, theme: { - title: ELASTIC_SECURITY_ASSISTANT_TITLE, + title: ELASTIC_AI_ASSISTANT_TITLE, titleIcon: 'logoSecurity', assistant: { - name: ELASTIC_SECURITY_ASSISTANT, + name: ELASTIC_AI_ASSISTANT, icon: 'logoSecurity', }, system: { @@ -35,6 +35,11 @@ interface AppendMessageProps { message: Message; } +interface AppendReplacementsProps { + conversationId: string; + replacements: Record; +} + interface CreateConversationProps { conversationId: string; messages?: Message[]; @@ -51,6 +56,10 @@ interface SetConversationProps { interface UseConversation { appendMessage: ({ conversationId: string, message: Message }: AppendMessageProps) => Message[]; + appendReplacements: ({ + conversationId, + replacements, + }: AppendReplacementsProps) => Record; clearConversation: (conversationId: string) => void; createConversation: ({ conversationId, @@ -93,9 +102,38 @@ export const useConversation = (): UseConversation => { [setConversations] ); - /** - * Clear the messages[] for a given conversationId - */ + const appendReplacements = useCallback( + ({ conversationId, replacements }: AppendReplacementsProps): Record => { + let allReplacements = replacements; + + setConversations((prev: Record) => { + const prevConversation: Conversation | undefined = prev[conversationId]; + + if (prevConversation != null) { + allReplacements = { + ...prevConversation.replacements, + ...replacements, + }; + + const newConversation = { + ...prevConversation, + replacements: allReplacements, + }; + + return { + ...prev, + [conversationId]: newConversation, + }; + } else { + return prev; + } + }); + + return allReplacements; + }, + [setConversations] + ); + const clearConversation = useCallback( (conversationId: string) => { setConversations((prev: Record) => { @@ -105,6 +143,7 @@ export const useConversation = (): UseConversation => { const newConversation = { ...prevConversation, messages: [], + replacements: undefined, }; return { @@ -210,6 +249,7 @@ export const useConversation = (): UseConversation => { return { appendMessage, + appendReplacements, clearConversation, createConversation, deleteConversation, diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_conversation/sample_conversations.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_conversation/sample_conversations.tsx index 3fc41160d5bbf..a49cd0e22a0f4 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_conversation/sample_conversations.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_conversation/sample_conversations.tsx @@ -9,8 +9,8 @@ import { Conversation } from '../../assistant_context/types'; import * as i18n from '../../content/prompts/welcome/translations'; import { DEFAULT_CONVERSATION_TITLE, - ELASTIC_SECURITY_ASSISTANT, - ELASTIC_SECURITY_ASSISTANT_TITLE, + ELASTIC_AI_ASSISTANT, + ELASTIC_AI_ASSISTANT_TITLE, WELCOME_CONVERSATION_TITLE, } from './translations'; @@ -87,10 +87,10 @@ export const BASE_CONVERSATIONS: Record = { [WELCOME_CONVERSATION_TITLE]: { id: WELCOME_CONVERSATION_TITLE, theme: { - title: ELASTIC_SECURITY_ASSISTANT_TITLE, + title: ELASTIC_AI_ASSISTANT_TITLE, titleIcon: 'logoSecurity', assistant: { - name: ELASTIC_SECURITY_ASSISTANT, + name: ELASTIC_AI_ASSISTANT, icon: 'logoSecurity', }, system: { diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_conversation/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_conversation/translations.ts index 48271c8b50629..e26e910b9953d 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_conversation/translations.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_conversation/translations.ts @@ -20,15 +20,15 @@ export const DEFAULT_CONVERSATION_TITLE = i18n.translate( } ); -export const ELASTIC_SECURITY_ASSISTANT_TITLE = i18n.translate( - 'xpack.elasticAssistant.assistant.useConversation.elasticSecurityAssistantTitle', +export const ELASTIC_AI_ASSISTANT_TITLE = i18n.translate( + 'xpack.elasticAssistant.assistant.useConversation.elasticAiAssistantTitle', { - defaultMessage: 'Elastic Security Assistant', + defaultMessage: 'Elastic AI Assistant', } ); -export const ELASTIC_SECURITY_ASSISTANT = i18n.translate( - 'xpack.elasticAssistant.assistant.useConversation.elasticSecurityAssistantName', +export const ELASTIC_AI_ASSISTANT = i18n.translate( + 'xpack.elasticAssistant.assistant.useConversation.elasticAiAssistantName', { defaultMessage: 'Assistant', } diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.test.tsx index 564d57ddd39fa..c410ef029fd6c 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.test.tsx @@ -21,10 +21,16 @@ const ContextWrapper: React.FC = ({ children }) => ( {children} diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.tsx index a460a60748386..a67aeb54f65a5 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.tsx @@ -7,7 +7,7 @@ import { EuiCommentProps } from '@elastic/eui'; import type { HttpSetup } from '@kbn/core-http-browser'; -import { omit } from 'lodash/fp'; +import { omit, uniq } from 'lodash/fp'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { ActionTypeRegistryContract } from '@kbn/triggers-actions-ui-plugin/public'; @@ -45,6 +45,10 @@ type ShowAssistantOverlay = ({ interface AssistantProviderProps { actionTypeRegistry: ActionTypeRegistryContract; augmentMessageCodeBlocks: (currentConversation: Conversation) => CodeBlockDetails[][]; + baseAllow: string[]; + baseAllowReplacement: string[]; + defaultAllow: string[]; + defaultAllowReplacement: string[]; basePromptContexts?: PromptContextTemplate[]; baseQuickPrompts?: QuickPrompt[]; baseSystemPrompts?: Prompt[]; @@ -52,14 +56,18 @@ interface AssistantProviderProps { getComments: ({ currentConversation, lastCommentRef, + showAnonymizedValues, }: { currentConversation: Conversation; lastCommentRef: React.MutableRefObject; + showAnonymizedValues: boolean; }) => EuiCommentProps[]; http: HttpSetup; getInitialConversations: () => Record; nameSpace?: string; setConversations: React.Dispatch>>; + setDefaultAllow: React.Dispatch>; + setDefaultAllowReplacement: React.Dispatch>; title?: string; } @@ -68,6 +76,10 @@ interface UseAssistantContext { augmentMessageCodeBlocks: (currentConversation: Conversation) => CodeBlockDetails[][]; allQuickPrompts: QuickPrompt[]; allSystemPrompts: Prompt[]; + baseAllow: string[]; + baseAllowReplacement: string[]; + defaultAllow: string[]; + defaultAllowReplacement: string[]; basePromptContexts: PromptContextTemplate[]; baseQuickPrompts: QuickPrompt[]; baseSystemPrompts: Prompt[]; @@ -76,9 +88,12 @@ interface UseAssistantContext { getComments: ({ currentConversation, lastCommentRef, + showAnonymizedValues, }: { currentConversation: Conversation; lastCommentRef: React.MutableRefObject; + + showAnonymizedValues: boolean; }) => EuiCommentProps[]; http: HttpSetup; promptContexts: Record; @@ -87,6 +102,8 @@ interface UseAssistantContext { setAllQuickPrompts: React.Dispatch>; setAllSystemPrompts: React.Dispatch>; setConversations: React.Dispatch>>; + setDefaultAllow: React.Dispatch>; + setDefaultAllowReplacement: React.Dispatch>; setShowAssistantOverlay: (showAssistantOverlay: ShowAssistantOverlay) => void; showAssistantOverlay: ShowAssistantOverlay; title: string; @@ -98,6 +115,10 @@ const AssistantContext = React.createContext(un export const AssistantProvider: React.FC = ({ actionTypeRegistry, augmentMessageCodeBlocks, + baseAllow, + baseAllowReplacement, + defaultAllow, + defaultAllowReplacement, basePromptContexts = [], baseQuickPrompts = [], baseSystemPrompts = BASE_SYSTEM_PROMPTS, @@ -107,6 +128,8 @@ export const AssistantProvider: React.FC = ({ getInitialConversations, nameSpace = DEFAULT_ASSISTANT_NAMESPACE, setConversations, + setDefaultAllow, + setDefaultAllowReplacement, title = DEFAULT_ASSISTANT_TITLE, }) => { /** @@ -202,11 +225,15 @@ export const AssistantProvider: React.FC = ({ augmentMessageCodeBlocks, allQuickPrompts: localStorageQuickPrompts ?? [], allSystemPrompts: localStorageSystemPrompts ?? [], + baseAllow: uniq(baseAllow), + baseAllowReplacement: uniq(baseAllowReplacement), basePromptContexts, baseQuickPrompts, baseSystemPrompts, conversationIds, conversations, + defaultAllow: uniq(defaultAllow), + defaultAllowReplacement: uniq(defaultAllowReplacement), getComments, http, promptContexts, @@ -215,6 +242,8 @@ export const AssistantProvider: React.FC = ({ setAllQuickPrompts: setLocalStorageQuickPrompts, setAllSystemPrompts: setLocalStorageSystemPrompts, setConversations: onConversationsUpdated, + setDefaultAllow, + setDefaultAllowReplacement, setShowAssistantOverlay, showAssistantOverlay, title, @@ -223,19 +252,25 @@ export const AssistantProvider: React.FC = ({ [ actionTypeRegistry, augmentMessageCodeBlocks, + baseAllow, + baseAllowReplacement, basePromptContexts, baseQuickPrompts, baseSystemPrompts, conversationIds, conversations, + defaultAllow, + defaultAllowReplacement, getComments, http, localStorageQuickPrompts, localStorageSystemPrompts, - promptContexts, nameSpace, - registerPromptContext, onConversationsUpdated, + promptContexts, + registerPromptContext, + setDefaultAllow, + setDefaultAllowReplacement, setLocalStorageQuickPrompts, setLocalStorageSystemPrompts, showAssistantOverlay, diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/types.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/types.tsx index e237b8855f731..ce49b55e5ef84 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/types.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/types.tsx @@ -51,6 +51,7 @@ export interface Conversation { }; id: string; messages: Message[]; + replacements?: Record; theme?: ConversationTheme; isDefault?: boolean; } diff --git a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/translations.ts index c9eb7797e362c..923cb916df4e6 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/translations.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/translations.ts @@ -11,7 +11,7 @@ export const LOAD_ACTIONS_ERROR_MESSAGE = i18n.translate( 'xpack.elasticAssistant.connectors.useLoadActionTypes.errorMessage', { defaultMessage: - 'Welcome to your Elastic Assistant! I am your 100% open-source portal into your Elastic Life. ', + 'Welcome to your Elastic AI Assistant! I am your 100% open-source portal into your Elastic Life. ', } ); @@ -19,7 +19,7 @@ export const LOAD_CONNECTORS_ERROR_MESSAGE = i18n.translate( 'xpack.elasticAssistant.connectors.useLoadConnectors.errorMessage', { defaultMessage: - 'Welcome to your Elastic Assistant! I am your 100% open-source portal into your Elastic Life. ', + 'Welcome to your Elastic AI Assistant! I am your 100% open-source portal into your Elastic Life. ', } ); @@ -27,7 +27,7 @@ export const WELCOME_SECURITY = i18n.translate( 'xpack.elasticAssistant.content.prompts.welcome.welcomeSecurityPrompt', { defaultMessage: - 'Welcome to your Elastic Assistant! I am your 100% open-source portal into Elastic Security. ', + 'Welcome to your Elastic AI Assistant! I am your 100% open-source portal into Elastic Security. ', } ); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/content/prompts/system/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/content/prompts/system/index.tsx index 3a11330446667..e2034cc62c33a 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/content/prompts/system/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/content/prompts/system/index.tsx @@ -14,7 +14,7 @@ import { } from './translations'; /** - * Base System Prompts for Elastic Assistant (if not overridden on initialization). + * Base System Prompts for Elastic AI Assistant (if not overridden on initialization). */ export const BASE_SYSTEM_PROMPTS: Prompt[] = [ { diff --git a/x-pack/packages/kbn-elastic-assistant/impl/content/prompts/system/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/content/prompts/system/translations.ts index 9a1c02eaccfc2..1a8b09f0c2aa1 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/content/prompts/system/translations.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/content/prompts/system/translations.ts @@ -11,7 +11,7 @@ export const YOU_ARE_A_HELPFUL_EXPERT_ASSISTANT = i18n.translate( 'xpack.elasticAssistant.assistant.content.prompts.system.youAreAHelpfulExpertAssistant', { defaultMessage: - 'You are a helpful, expert assistant who only answers questions about Elastic Security.', + 'You are a helpful, expert assistant who answers questions about Elastic Security.', } ); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/content/prompts/welcome/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/content/prompts/welcome/translations.ts index 6a6b1253ca2b1..8c28f1a8fa3f3 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/content/prompts/welcome/translations.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/content/prompts/welcome/translations.ts @@ -11,7 +11,7 @@ export const WELCOME_GENERAL = i18n.translate( 'xpack.elasticAssistant.securityAssistant.content.prompts.welcome.welcomeGeneralPrompt', { defaultMessage: - 'Welcome to your Elastic Assistant! I am your 100% open-code portal into your Elastic life. In time, I will be able to answer questions and provide assistance across all your information in Elastic, and oh-so much more. Till then, I hope this early preview will open your mind to the possibilities of what we can create when we work together, in the open. Cheers!', + 'Welcome to your Elastic AI Assistant! I am your 100% open-code portal into your Elastic life. In time, I will be able to answer questions and provide assistance across all your information in Elastic, and oh-so much more. Till then, I hope this early preview will open your mind to the possibilities of what we can create when we work together, in the open. Cheers!', } ); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/get_anonymized_data/index.test.ts b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/get_anonymized_data/index.test.ts new file mode 100644 index 0000000000000..abfb627376fc3 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/get_anonymized_data/index.test.ts @@ -0,0 +1,54 @@ +/* + * 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 { getAnonymizedValues } from '../get_anonymized_values'; +import { mockGetAnonymizedValue } from '../../mock/get_anonymized_value'; +import { getAnonymizedData } from '.'; + +describe('getAnonymizedData', () => { + const rawData: Record = { + doNotReplace: ['this-will-not-be-replaced', 'neither-will-this'], + empty: [], + 'host.ip': ['127.0.0.1', '10.0.0.1'], + 'host.name': ['test-host'], + doNotInclude: ['this-will-not-be-included', 'neither-will-this'], + }; + + const commonArgs = { + allow: ['doNotReplace', 'empty', 'host.ip', 'host.name'], + allowReplacement: ['empty', 'host.ip', 'host.name'], + currentReplacements: {}, + rawData, + getAnonymizedValue: mockGetAnonymizedValue, + getAnonymizedValues, + }; + + it('returns the expected anonymized data', () => { + const result = getAnonymizedData({ + ...commonArgs, + }); + + expect(result.anonymizedData).toEqual({ + doNotReplace: ['this-will-not-be-replaced', 'neither-will-this'], + empty: [], + 'host.ip': ['1.0.0.721', '1.0.0.01'], + 'host.name': ['tsoh-tset'], + }); + }); + + it('returns the expected map of replaced value to original value', () => { + const result = getAnonymizedData({ + ...commonArgs, + }); + + expect(result.replacements).toEqual({ + '1.0.0.721': '127.0.0.1', + '1.0.0.01': '10.0.0.1', + 'tsoh-tset': 'test-host', + }); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/get_anonymized_data/index.ts b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/get_anonymized_data/index.ts new file mode 100644 index 0000000000000..a0ecd88234313 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/get_anonymized_data/index.ts @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { SelectedPromptContext } from '../../assistant/prompt_context/types'; +import { isAllowed } from '../../data_anonymization_editor/helpers'; +import type { AnonymizedData, GetAnonymizedValues } from '../types'; + +export const getAnonymizedData = ({ + allow, + allowReplacement, + currentReplacements, + getAnonymizedValue, + getAnonymizedValues, + rawData, +}: { + allow: SelectedPromptContext['allow']; + allowReplacement: SelectedPromptContext['allowReplacement']; + currentReplacements: Record | undefined; + getAnonymizedValue: ({ + currentReplacements, + rawValue, + }: { + currentReplacements: Record | undefined; + rawValue: string; + }) => string; + getAnonymizedValues: GetAnonymizedValues; + rawData: Record; +}): AnonymizedData => + Object.keys(rawData).reduce( + (acc, field) => { + const allowReplacementSet = new Set(allowReplacement); + const allowSet = new Set(allow); + + if (isAllowed({ allowSet, field })) { + const { anonymizedValues, replacements } = getAnonymizedValues({ + allowReplacementSet, + allowSet, + currentReplacements, + field, + getAnonymizedValue, + rawData, + }); + + return { + anonymizedData: { + ...acc.anonymizedData, + [field]: anonymizedValues, + }, + replacements: { + ...acc.replacements, + ...replacements, + }, + }; + } else { + return acc; + } + }, + { + anonymizedData: {}, + replacements: {}, + } + ); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/get_anonymized_values/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/get_anonymized_values/index.test.tsx new file mode 100644 index 0000000000000..24e95ca0f0bb0 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/get_anonymized_values/index.test.tsx @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getAnonymizedValues } from '.'; +import { mockGetAnonymizedValue } from '../../mock/get_anonymized_value'; + +describe('getAnonymizedValues', () => { + it('returns empty anonymizedValues and replacements when provided with empty raw data', () => { + const result = getAnonymizedValues({ + allowReplacementSet: new Set(), + allowSet: new Set(), + currentReplacements: {}, + field: 'test.field', + getAnonymizedValue: jest.fn(), + rawData: {}, + }); + + expect(result).toEqual({ + anonymizedValues: [], + replacements: {}, + }); + }); + + it('returns the expected anonymized values', () => { + const rawData = { + 'test.field': ['test1', 'test2'], + }; + + const result = getAnonymizedValues({ + allowReplacementSet: new Set(['test.field']), + allowSet: new Set(['test.field']), + currentReplacements: {}, + field: 'test.field', + getAnonymizedValue: mockGetAnonymizedValue, + rawData, + }); + + expect(result.anonymizedValues).toEqual(['1tset', '2tset']); + }); + + it('returns the expected replacements', () => { + const rawData = { + 'test.field': ['test1', 'test2'], + }; + + const result = getAnonymizedValues({ + allowReplacementSet: new Set(['test.field']), + allowSet: new Set(['test.field']), + currentReplacements: {}, + field: 'test.field', + getAnonymizedValue: mockGetAnonymizedValue, + rawData, + }); + + expect(result.replacements).toEqual({ + '1tset': 'test1', + '2tset': 'test2', + }); + }); + + it('returns non-anonymized values when the field is not a member of the `allowReplacementSet`', () => { + const rawData = { + 'test.field': ['test1', 'test2'], + }; + + const result = getAnonymizedValues({ + allowReplacementSet: new Set(), // does NOT include `test.field` + allowSet: new Set(['test.field']), + currentReplacements: {}, + field: 'test.field', + getAnonymizedValue: mockGetAnonymizedValue, + rawData, + }); + + expect(result.anonymizedValues).toEqual(['test1', 'test2']); // no anonymization + }); + + it('does NOT allow a field to be included in `anonymizedValues` when the field is not a member of the `allowSet`', () => { + const rawData = { + 'test.field': ['test1', 'test2'], + }; + + const result = getAnonymizedValues({ + allowReplacementSet: new Set(['test.field']), + allowSet: new Set(), // does NOT include `test.field` + currentReplacements: {}, + field: 'test.field', + getAnonymizedValue: mockGetAnonymizedValue, + rawData, + }); + + expect(result.anonymizedValues).toEqual([]); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/get_anonymized_values/index.ts b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/get_anonymized_values/index.ts new file mode 100644 index 0000000000000..db846f93bf112 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/get_anonymized_values/index.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 { isAllowed, isAnonymized } from '../../data_anonymization_editor/helpers'; +import { AnonymizedValues, GetAnonymizedValues } from '../types'; + +export const getAnonymizedValues: GetAnonymizedValues = ({ + allowSet, + allowReplacementSet, + currentReplacements, + field, + getAnonymizedValue, + rawData, +}): AnonymizedValues => { + const rawValues = rawData[field] ?? []; + + return rawValues.reduce( + (acc, rawValue) => { + if (isAllowed({ allowSet, field }) && isAnonymized({ allowReplacementSet, field })) { + const anonymizedValue = getAnonymizedValue({ currentReplacements, rawValue }); + + return { + anonymizedValues: [...acc.anonymizedValues, anonymizedValue], + replacements: { + ...acc.replacements, + [anonymizedValue]: rawValue, + }, + }; + } else if (isAllowed({ allowSet, field })) { + return { + anonymizedValues: [...acc.anonymizedValues, rawValue], // no anonymization for this value + replacements: { + ...acc.replacements, // no additional replacements + }, + }; + } else { + return acc; + } + }, + { + anonymizedValues: [], + replacements: {}, + } + ); +}; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/get_csv_from_data/index.test.ts b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/get_csv_from_data/index.test.ts new file mode 100644 index 0000000000000..b1c08d29b1b95 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/get_csv_from_data/index.test.ts @@ -0,0 +1,54 @@ +/* + * 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 { getCsvFromData } from '.'; + +describe('getCsvFromData', () => { + it('returns the expected csv', () => { + const data: Record = { + a: ['1', '2', '3'], + b: ['4', '5', '6'], + c: ['7', '8', '9'], + }; + + const result = getCsvFromData(data); + + expect(result).toBe('a,1,2,3\nb,4,5,6\nc,7,8,9'); + }); + + it('returns an empty string for empty data', () => { + const data: Record = {}; + + const result = getCsvFromData(data); + + expect(result).toBe(''); + }); + + it('sorts the keys alphabetically', () => { + const data: Record = { + b: ['1', '2', '3'], + a: ['4', '5', '6'], + c: ['7', '8', '9'], + }; + + const result = getCsvFromData(data); + + expect(result).toBe('a,4,5,6\nb,1,2,3\nc,7,8,9'); + }); + + it('correctly handles single-element arrays', () => { + const data: Record = { + a: ['1'], + b: ['2'], + c: ['3'], + }; + + const result = getCsvFromData(data); + + expect(result).toBe('a,1\nb,2\nc,3'); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/get_csv_from_data/index.ts b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/get_csv_from_data/index.ts new file mode 100644 index 0000000000000..4bef8a0218f6d --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/get_csv_from_data/index.ts @@ -0,0 +1,12 @@ +/* + * 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 const getCsvFromData = (data: Record): string => + Object.keys(data) + .sort() + .map((key) => `${key},${data[key].join(',')}`) + .join('\n'); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/get_new_selected_prompt_context/index.test.ts b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/get_new_selected_prompt_context/index.test.ts new file mode 100644 index 0000000000000..fb2577c91d097 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/get_new_selected_prompt_context/index.test.ts @@ -0,0 +1,74 @@ +/* + * 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 { PromptContext, SelectedPromptContext } from '../../assistant/prompt_context/types'; +import { mockAlertPromptContext } from '../../mock/prompt_context'; +import { getNewSelectedPromptContext } from '.'; + +describe('getNewSelectedPromptContext', () => { + const defaultAllow = ['field1', 'field2']; + const defaultAllowReplacement = ['field3', 'field4']; + + it("returns empty `allow` and `allowReplacement` for string `rawData`, because it's not anonymized", async () => { + const promptContext: PromptContext = { + ...mockAlertPromptContext, + getPromptContext: () => Promise.resolve('string data'), // not anonymized + }; + + const result = await getNewSelectedPromptContext({ + defaultAllow, + defaultAllowReplacement, + promptContext, + }); + + const excepted: SelectedPromptContext = { + allow: [], + allowReplacement: [], + promptContextId: promptContext.id, + rawData: 'string data', + }; + + expect(result).toEqual(excepted); + }); + + it('returns `allow` and `allowReplacement` with the contents of `defaultAllow` and `defaultAllowReplacement` for object rawData, which is anonymized', async () => { + const promptContext: PromptContext = { + ...mockAlertPromptContext, + getPromptContext: () => Promise.resolve({ field1: ['value1'], field2: ['value2'] }), + }; + + const excepted: SelectedPromptContext = { + allow: [...defaultAllow], + allowReplacement: [...defaultAllowReplacement], + promptContextId: promptContext.id, + rawData: { field1: ['value1'], field2: ['value2'] }, + }; + + const result = await getNewSelectedPromptContext({ + defaultAllow, + defaultAllowReplacement, + promptContext, + }); + + expect(result).toEqual(excepted); + }); + + it('calls getPromptContext from the given promptContext', async () => { + const promptContext: PromptContext = { + ...mockAlertPromptContext, + getPromptContext: jest.fn(() => Promise.resolve('string data')), + }; + + await getNewSelectedPromptContext({ + defaultAllow, + defaultAllowReplacement, + promptContext, + }); + + expect(promptContext.getPromptContext).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/get_new_selected_prompt_context/index.ts b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/get_new_selected_prompt_context/index.ts new file mode 100644 index 0000000000000..daf64ee590ae0 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/get_new_selected_prompt_context/index.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { PromptContext, SelectedPromptContext } from '../../assistant/prompt_context/types'; + +export async function getNewSelectedPromptContext({ + defaultAllow, + defaultAllowReplacement, + promptContext, +}: { + defaultAllow: string[]; + defaultAllowReplacement: string[]; + promptContext: PromptContext; +}): Promise { + const rawData = await promptContext.getPromptContext(); + + if (typeof rawData === 'string') { + return { + allow: [], + allowReplacement: [], + promptContextId: promptContext.id, + rawData, + }; + } else { + return { + allow: [...defaultAllow], + allowReplacement: [...defaultAllowReplacement], + promptContextId: promptContext.id, + rawData, + }; + } +} diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/anonymization_settings/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/anonymization_settings/index.test.tsx new file mode 100644 index 0000000000000..583c0d8076a68 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/anonymization_settings/index.test.tsx @@ -0,0 +1,159 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; + +import { TestProviders } from '../../../mock/test_providers/test_providers'; +import { AnonymizationSettings } from '.'; + +const mockUseAssistantContext = { + allSystemPrompts: [ + { + id: 'default-system-prompt', + content: 'default', + name: 'default', + promptType: 'system', + isDefault: true, + isNewConversationDefault: true, + }, + { + id: 'CB9FA555-B59F-4F71-AFF9-8A891AC5BC28', + content: 'superhero', + name: 'superhero', + promptType: 'system', + isDefault: true, + }, + ], + baseAllow: ['@timestamp', 'event.category', 'user.name'], + baseAllowReplacement: ['user.name', 'host.ip'], + defaultAllow: ['foo', 'bar', 'baz', '@baz'], + defaultAllowReplacement: ['bar'], + setAllSystemPrompts: jest.fn(), + setDefaultAllow: jest.fn(), + setDefaultAllowReplacement: jest.fn(), +}; +jest.mock('../../../assistant_context', () => { + const original = jest.requireActual('../../../assistant_context'); + + return { + ...original, + useAssistantContext: () => mockUseAssistantContext, + }; +}); + +describe('AnonymizationSettings', () => { + const closeModal = jest.fn(); + + beforeEach(() => jest.clearAllMocks()); + + it('renders the editor', () => { + const { getByTestId } = render( + + + + ); + + expect(getByTestId('contextEditor')).toBeInTheDocument(); + }); + + it('does NOT call `setDefaultAllow` when `Reset` is clicked, because only local state is reset until the user clicks save', () => { + const { getByTestId } = render( + + + + ); + + fireEvent.click(getByTestId('reset')); + + expect(mockUseAssistantContext.setDefaultAllow).not.toHaveBeenCalled(); + }); + + it('does NOT call `setDefaultAllowReplacement` when `Reset` is clicked, because only local state is reset until the user clicks save', () => { + const { getByTestId } = render( + + + + ); + + fireEvent.click(getByTestId('reset')); + + expect(mockUseAssistantContext.setDefaultAllowReplacement).not.toHaveBeenCalled(); + }); + + it('renders the expected allowed stat content', () => { + const { getByTestId } = render( + + + + ); + + expect(getByTestId('allowedStat')).toHaveTextContent( + `${mockUseAssistantContext.defaultAllow.length}Allowed` + ); + }); + + it('renders the expected anonymized stat content', () => { + const { getByTestId } = render( + + + + ); + + expect(getByTestId('anonymizedFieldsStat')).toHaveTextContent( + `${mockUseAssistantContext.defaultAllowReplacement.length}Anonymized` + ); + }); + + it('calls closeModal is called when the cancel button is clicked', () => { + const { getByTestId } = render( + + + + ); + fireEvent.click(getByTestId('cancel')); + expect(closeModal).toHaveBeenCalledTimes(1); + }); + + it('calls closeModal is called when the save button is clicked', () => { + const { getByTestId } = render( + + + + ); + fireEvent.click(getByTestId('cancel')); + expect(closeModal).toHaveBeenCalledTimes(1); + }); + + it('calls setDefaultAllow with the expected values when the save button is clicked', () => { + const { getByTestId } = render( + + + + ); + + fireEvent.click(getByTestId('save')); + + expect(mockUseAssistantContext.setDefaultAllow).toHaveBeenCalledWith( + mockUseAssistantContext.defaultAllow + ); + }); + + it('calls setDefaultAllowReplacement with the expected values when the save button is clicked', () => { + const { getByTestId } = render( + + + + ); + + fireEvent.click(getByTestId('save')); + + expect(mockUseAssistantContext.setDefaultAllowReplacement).toHaveBeenCalledWith( + mockUseAssistantContext.defaultAllowReplacement + ); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/anonymization_settings/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/anonymization_settings/index.tsx new file mode 100644 index 0000000000000..e833276d4850d --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/anonymization_settings/index.tsx @@ -0,0 +1,147 @@ +/* + * 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 { + EuiButton, + EuiButtonEmpty, + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, +} from '@elastic/eui'; +import React, { useCallback, useMemo, useState } from 'react'; +// eslint-disable-next-line @kbn/eslint/module_migration +import styled from 'styled-components'; + +import { useAssistantContext } from '../../../assistant_context'; +import { ContextEditor } from '../../../data_anonymization_editor/context_editor'; +import type { BatchUpdateListItem } from '../../../data_anonymization_editor/context_editor/types'; +import { updateDefaults } from '../../../data_anonymization_editor/helpers'; +import { AllowedStat } from '../../../data_anonymization_editor/stats/allowed_stat'; +import { AnonymizedStat } from '../../../data_anonymization_editor/stats/anonymized_stat'; +import { CANCEL, SAVE } from '../anonymization_settings_modal/translations'; +import * as i18n from './translations'; + +const StatFlexItem = styled(EuiFlexItem)` + margin-right: ${({ theme }) => theme.eui.euiSizeL}; +`; + +interface Props { + closeModal?: () => void; +} + +const AnonymizationSettingsComponent: React.FC = ({ closeModal }) => { + const { + baseAllow, + baseAllowReplacement, + defaultAllow, + defaultAllowReplacement, + setDefaultAllow, + setDefaultAllowReplacement, + } = useAssistantContext(); + + // Local state for default allow and default allow replacement to allow for intermediate changes + const [localDefaultAllow, setLocalDefaultAllow] = useState(defaultAllow); + const [localDefaultAllowReplacement, setLocalDefaultAllowReplacement] = + useState(defaultAllowReplacement); + + const onListUpdated = useCallback( + (updates: BatchUpdateListItem[]) => { + updateDefaults({ + defaultAllow: localDefaultAllow, + defaultAllowReplacement: localDefaultAllowReplacement, + setDefaultAllow: setLocalDefaultAllow, + setDefaultAllowReplacement: setLocalDefaultAllowReplacement, + updates, + }); + }, + [localDefaultAllow, localDefaultAllowReplacement] + ); + + const onReset = useCallback(() => { + setLocalDefaultAllow(baseAllow); + setLocalDefaultAllowReplacement(baseAllowReplacement); + }, [baseAllow, baseAllowReplacement]); + + const onSave = useCallback(() => { + setDefaultAllow(localDefaultAllow); + setDefaultAllowReplacement(localDefaultAllowReplacement); + closeModal?.(); + }, [ + closeModal, + localDefaultAllow, + localDefaultAllowReplacement, + setDefaultAllow, + setDefaultAllowReplacement, + ]); + + const anonymized: number = useMemo(() => { + const allowSet = new Set(localDefaultAllow); + + return localDefaultAllowReplacement.reduce( + (acc, field) => (allowSet.has(field) ? acc + 1 : acc), + 0 + ); + }, [localDefaultAllow, localDefaultAllowReplacement]); + + return ( + <> + +

{i18n.CALLOUT_PARAGRAPH1}

+ + {i18n.RESET} + +
+ + + + + + + + + + + + + + + + + + + {closeModal != null && ( + + + {CANCEL} + + + )} + + + + {SAVE} + + + + + ); +}; + +AnonymizationSettingsComponent.displayName = 'AnonymizationSettingsComponent'; + +export const AnonymizationSettings = React.memo(AnonymizationSettingsComponent); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/anonymization_settings/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/anonymization_settings/translations.ts new file mode 100644 index 0000000000000..e7f82289dff78 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/anonymization_settings/translations.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const CALLOUT_PARAGRAPH1 = i18n.translate( + 'xpack.elasticAssistant.dataAnonymization.settings.anonymizationSettings.calloutParagraph1', + { + defaultMessage: 'The fields below are allowed by default', + } +); + +export const CALLOUT_PARAGRAPH2 = i18n.translate( + 'xpack.elasticAssistant.dataAnonymization.settings.anonymizationSettings.calloutParagraph2', + { + defaultMessage: 'Optionally enable anonymization for these fields', + } +); + +export const CALLOUT_TITLE = i18n.translate( + 'xpack.elasticAssistant.dataAnonymization.settings.anonymizationSettings.calloutTitle', + { + defaultMessage: 'Anonymization defaults', + } +); + +export const RESET = i18n.translate( + 'xpack.elasticAssistant.dataAnonymization.settings.anonymizationSettings.resetButton', + { + defaultMessage: 'Reset', + } +); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/anonymization_settings_modal/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/anonymization_settings_modal/index.test.tsx new file mode 100644 index 0000000000000..35ee1a1bde473 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/anonymization_settings_modal/index.test.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, fireEvent, screen } from '@testing-library/react'; + +import { AnonymizationSettingsModal } from '.'; +import { TestProviders } from '../../../mock/test_providers/test_providers'; + +describe('AnonymizationSettingsModal', () => { + const closeModal = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + + render( + + + + ); + }); + + it('renders the anonymizationSettings', () => { + expect(screen.getByTestId('anonymizationSettingsCallout')).toBeInTheDocument(); + }); + + it('calls closeModal when Cancel is clicked', () => { + fireEvent.click(screen.getByTestId('cancel')); + + expect(closeModal).toHaveBeenCalledTimes(1); + }); + + it('calls closeModal when Save is clicked', () => { + fireEvent.click(screen.getByTestId('save')); + + expect(closeModal).toHaveBeenCalledTimes(1); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/anonymization_settings_modal/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/anonymization_settings_modal/index.tsx new file mode 100644 index 0000000000000..ae4b395ca0d0e --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/anonymization_settings_modal/index.tsx @@ -0,0 +1,26 @@ +/* + * 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 { EuiModal, EuiModalBody, EuiModalHeader } from '@elastic/eui'; +import React from 'react'; + +import { AnonymizationSettings } from '../anonymization_settings'; + +interface Props { + closeModal: () => void; +} + +const AnonymizationSettingsModalComponent: React.FC = ({ closeModal }) => ( + + + + + + +); + +export const AnonymizationSettingsModal = React.memo(AnonymizationSettingsModalComponent); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/anonymization_settings_modal/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/anonymization_settings_modal/translations.ts new file mode 100644 index 0000000000000..d3da99dcf5052 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/anonymization_settings_modal/translations.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 { i18n } from '@kbn/i18n'; + +export const ANONYMIZATION = i18n.translate( + 'xpack.elasticAssistant.dataAnonymization.settings.anonymizationSettingsModal.anonymizationModalTitle', + { + defaultMessage: 'Anonymization', + } +); + +export const CANCEL = i18n.translate( + 'xpack.elasticAssistant.dataAnonymization.settings.anonymizationSettingsModal.cancelButton', + { + defaultMessage: 'Cancel', + } +); + +export const SAVE = i18n.translate( + 'xpack.elasticAssistant.dataAnonymization.settings.anonymizationSettingsModal.saveButton', + { + defaultMessage: 'Save', + } +); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/settings_popover/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/settings_popover/index.test.tsx new file mode 100644 index 0000000000000..3168c6a6b28dc --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/settings_popover/index.test.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { TestProviders } from '../../../mock/test_providers/test_providers'; +import * as i18n from './translations'; +import { SettingsPopover } from '.'; + +describe('SettingsPopover', () => { + beforeEach(() => { + render( + + + + ); + }); + + it('renders the settings button', () => { + const settingsButton = screen.getByTestId('settings'); + + expect(settingsButton).toBeInTheDocument(); + }); + + it('opens the popover when the settings button is clicked', () => { + const settingsButton = screen.getByTestId('settings'); + + userEvent.click(settingsButton); + + const popover = screen.queryByText(i18n.ANONYMIZATION); + expect(popover).toBeInTheDocument(); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/settings_popover/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/settings_popover/index.tsx new file mode 100644 index 0000000000000..e623798e8c4dd --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/settings_popover/index.tsx @@ -0,0 +1,89 @@ +/* + * 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 { + EuiButtonIcon, + EuiContextMenu, + EuiContextMenuPanelDescriptor, + EuiPopover, + useGeneratedHtmlId, +} from '@elastic/eui'; +import React, { useCallback, useMemo, useState } from 'react'; +import { AnonymizationSettingsModal } from '../anonymization_settings_modal'; + +import * as i18n from './translations'; + +const SettingsPopoverComponent: React.FC = () => { + const [showAnonymizationSettingsModal, setShowAnonymizationSettingsModal] = useState(false); + const closeAnonymizationSettingsModal = useCallback( + () => setShowAnonymizationSettingsModal(false), + [] + ); + + const contextMenuPopoverId = useGeneratedHtmlId(); + + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const closePopover = useCallback(() => setIsPopoverOpen(false), []); + + const onButtonClick = useCallback(() => setIsPopoverOpen((prev) => !prev), []); + const button = useMemo( + () => ( + + ), + [onButtonClick] + ); + + const panels: EuiContextMenuPanelDescriptor[] = useMemo( + () => [ + { + id: 0, + items: [ + { + icon: 'eyeClosed', + name: i18n.ANONYMIZATION, + onClick: () => { + closePopover(); + + setShowAnonymizationSettingsModal(true); + }, + }, + ], + size: 's', + width: 150, + }, + ], + [closePopover] + ); + + return ( + <> + + + + + {showAnonymizationSettingsModal && ( + + )} + + ); +}; + +SettingsPopoverComponent.displayName = 'SettingsPopoverComponent'; + +export const SettingsPopover = React.memo(SettingsPopoverComponent); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/settings_popover/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/settings_popover/translations.ts new file mode 100644 index 0000000000000..4fcbcfcfa596b --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/settings_popover/translations.ts @@ -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 { i18n } from '@kbn/i18n'; + +export const ANONYMIZATION = i18n.translate( + 'xpack.elasticAssistant.dataAnonymization.settings.settingsPopover.anonymizationMenuItem', + { + defaultMessage: 'Anonymization', + } +); + +export const SETTINGS = i18n.translate( + 'xpack.elasticAssistant.dataAnonymization.settings.settingsPopover.settingsAriaLabel', + { + defaultMessage: 'Settings', + } +); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/transform_raw_data/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/transform_raw_data/index.test.tsx new file mode 100644 index 0000000000000..48caf7ca226dc --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/transform_raw_data/index.test.tsx @@ -0,0 +1,90 @@ +/* + * 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 { SelectedPromptContext } from '../../assistant/prompt_context/types'; +import { mockGetAnonymizedValue } from '../../mock/get_anonymized_value'; +import { transformRawData } from '.'; + +describe('transformRawData', () => { + it('returns non-anonymized data when rawData is a string', () => { + const inputRawData: SelectedPromptContext = { + allow: ['field1'], + allowReplacement: ['field1', 'field2'], + promptContextId: 'abcd', + rawData: 'this will not be anonymized', + }; + + const result = transformRawData({ + currentReplacements: {}, + getAnonymizedValue: mockGetAnonymizedValue, + onNewReplacements: () => {}, + selectedPromptContext: inputRawData, + }); + + expect(result).toEqual('this will not be anonymized'); + }); + + it('calls onNewReplacements with the expected replacements', () => { + const inputRawData: SelectedPromptContext = { + allow: ['field1'], + allowReplacement: ['field1'], + promptContextId: 'abcd', + rawData: { field1: ['value1'] }, + }; + + const onNewReplacements = jest.fn(); + + transformRawData({ + currentReplacements: {}, + getAnonymizedValue: mockGetAnonymizedValue, + onNewReplacements, + selectedPromptContext: inputRawData, + }); + + expect(onNewReplacements).toHaveBeenCalledWith({ '1eulav': 'value1' }); + }); + + it('returns the expected mix of anonymized and non-anonymized data as a CSV string', () => { + const inputRawData: SelectedPromptContext = { + allow: ['field1', 'field2'], + allowReplacement: ['field1'], // only field 1 will be anonymized + promptContextId: 'abcd', + rawData: { field1: ['value1', 'value2'], field2: ['value3', 'value4'] }, + }; + + const result = transformRawData({ + currentReplacements: {}, + getAnonymizedValue: mockGetAnonymizedValue, + onNewReplacements: () => {}, + selectedPromptContext: inputRawData, + }); + + expect(result).toEqual('field1,1eulav,2eulav\nfield2,value3,value4'); // only field 1 is anonymized + }); + + it('omits fields that are not included in the `allow` list, even if they are members of `allowReplacement`', () => { + const inputRawData: SelectedPromptContext = { + allow: ['field1', 'field2'], // field3 is NOT allowed + allowReplacement: ['field1', 'field3'], // field3 is requested to be anonymized + promptContextId: 'abcd', + rawData: { + field1: ['value1', 'value2'], + field2: ['value3', 'value4'], + field3: ['value5', 'value6'], // this data should NOT be included in the output + }, + }; + + const result = transformRawData({ + currentReplacements: {}, + getAnonymizedValue: mockGetAnonymizedValue, + onNewReplacements: () => {}, + selectedPromptContext: inputRawData, + }); + + expect(result).toEqual('field1,1eulav,2eulav\nfield2,value3,value4'); // field 3 is not included + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/transform_raw_data/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/transform_raw_data/index.tsx new file mode 100644 index 0000000000000..c478b0ab39f68 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/transform_raw_data/index.tsx @@ -0,0 +1,48 @@ +/* + * 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 { SelectedPromptContext } from '../../assistant/prompt_context/types'; +import { getAnonymizedData } from '../get_anonymized_data'; +import { getAnonymizedValues } from '../get_anonymized_values'; +import { getCsvFromData } from '../get_csv_from_data'; + +export const transformRawData = ({ + currentReplacements, + getAnonymizedValue, + onNewReplacements, + selectedPromptContext, +}: { + currentReplacements: Record | undefined; + getAnonymizedValue: ({ + currentReplacements, + rawValue, + }: { + currentReplacements: Record | undefined; + rawValue: string; + }) => string; + onNewReplacements?: (replacements: Record) => void; + selectedPromptContext: SelectedPromptContext; +}): string => { + if (typeof selectedPromptContext.rawData === 'string') { + return selectedPromptContext.rawData; + } + + const anonymizedData = getAnonymizedData({ + allow: selectedPromptContext.allow, + allowReplacement: selectedPromptContext.allowReplacement, + currentReplacements, + rawData: selectedPromptContext.rawData, + getAnonymizedValue, + getAnonymizedValues, + }); + + if (onNewReplacements != null) { + onNewReplacements(anonymizedData.replacements); + } + + return getCsvFromData(anonymizedData.anonymizedData); +}; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/types.ts b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/types.ts new file mode 100644 index 0000000000000..59ca12414b640 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/types.ts @@ -0,0 +1,44 @@ +/* + * 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 AnonymizedValues { + /** The original values were transformed to these anonymized values */ + anonymizedValues: string[]; + + /** A map from replacement value to original value */ + replacements: Record; +} + +export interface AnonymizedData { + /** The original data was transformed to this anonymized data */ + anonymizedData: Record; + + /** A map from replacement value to original value */ + replacements: Record; +} + +export type GetAnonymizedValues = ({ + allowReplacementSet, + allowSet, + currentReplacements, + field, + getAnonymizedValue, + rawData, +}: { + allowReplacementSet: Set; + allowSet: Set; + currentReplacements: Record | undefined; + field: string; + getAnonymizedValue: ({ + currentReplacements, + rawValue, + }: { + currentReplacements: Record | undefined; + rawValue: string; + }) => string; + rawData: Record; +}) => AnonymizedValues; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/bulk_actions/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/bulk_actions/index.test.tsx new file mode 100644 index 0000000000000..ec2f276f6f4bd --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/bulk_actions/index.test.tsx @@ -0,0 +1,139 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { BulkActions } from '.'; + +const selected = [ + { + allowed: true, + anonymized: false, + denied: false, + field: 'process.args', + rawValues: ['abc', 'def'], + }, + { + allowed: false, + anonymized: true, + denied: true, + field: 'user.name', + rawValues: ['fooUser'], + }, +]; + +const defaultProps = { + appliesTo: 'multipleRows' as const, + disabled: false, + onListUpdated: jest.fn(), + onlyDefaults: false, + selected, +}; + +describe('BulkActions', () => { + beforeEach(() => jest.clearAllMocks()); + + it('calls onListUpdated with the expected updates when Allow is clicked', () => { + const { getByTestId, getByText } = render(); + + userEvent.click(getByTestId('bulkActionsButton')); + fireEvent.click(getByText(/^Allow$/)); + + expect(defaultProps.onListUpdated).toBeCalledWith([ + { field: 'process.args', operation: 'add', update: 'allow' }, + { field: 'user.name', operation: 'add', update: 'allow' }, + ]); + }); + + it('calls onListUpdated with the expected updates when Deny is clicked', () => { + const { getByTestId, getByText } = render(); + + userEvent.click(getByTestId('bulkActionsButton')); + fireEvent.click(getByText(/^Deny$/)); + + expect(defaultProps.onListUpdated).toBeCalledWith([ + { field: 'process.args', operation: 'remove', update: 'allow' }, + { field: 'user.name', operation: 'remove', update: 'allow' }, + ]); + }); + + it('calls onListUpdated with the expected updates when Anonymize is clicked', () => { + const { getByTestId, getByText } = render(); + + userEvent.click(getByTestId('bulkActionsButton')); + fireEvent.click(getByText(/^Anonymize$/)); + + expect(defaultProps.onListUpdated).toBeCalledWith([ + { field: 'process.args', operation: 'add', update: 'allowReplacement' }, + { field: 'user.name', operation: 'add', update: 'allowReplacement' }, + ]); + }); + + it('calls onListUpdated with the expected updates when Unanonymize is clicked', () => { + const { getByTestId, getByText } = render(); + + userEvent.click(getByTestId('bulkActionsButton')); + fireEvent.click(getByText(/^Unanonymize$/)); + + expect(defaultProps.onListUpdated).toBeCalledWith([ + { field: 'process.args', operation: 'remove', update: 'allowReplacement' }, + { field: 'user.name', operation: 'remove', update: 'allowReplacement' }, + ]); + }); + + it('calls onListUpdated with the expected updates when Deny by default is clicked', () => { + const { getByTestId, getByText } = render( + + ); + + userEvent.click(getByTestId('bulkActionsButton')); + fireEvent.click(getByText(/^Deny by default$/)); + + expect(defaultProps.onListUpdated).toBeCalledWith([ + { field: 'process.args', operation: 'remove', update: 'allow' }, + { field: 'user.name', operation: 'remove', update: 'allow' }, + { field: 'process.args', operation: 'remove', update: 'defaultAllow' }, + { field: 'user.name', operation: 'remove', update: 'defaultAllow' }, + ]); + }); + + it('calls onListUpdated with the expected updates when Anonymize by default is clicked', () => { + const { getByTestId, getByText } = render( + + ); + + userEvent.click(getByTestId('bulkActionsButton')); + fireEvent.click(getByText(/^Defaults$/)); + fireEvent.click(getByText(/^Anonymize by default$/)); + + expect(defaultProps.onListUpdated).toBeCalledWith([ + { field: 'process.args', operation: 'add', update: 'allowReplacement' }, + { field: 'user.name', operation: 'add', update: 'allowReplacement' }, + { field: 'process.args', operation: 'add', update: 'defaultAllowReplacement' }, + { field: 'user.name', operation: 'add', update: 'defaultAllowReplacement' }, + ]); + }); + + it('calls onListUpdated with the expected updates when Unanonymize by default is clicked', () => { + const { getByTestId, getByText } = render( + + ); + + userEvent.click(getByTestId('bulkActionsButton')); + fireEvent.click(getByText(/^Defaults$/)); + fireEvent.click(getByText(/^Unanonymize by default$/)); + + expect(defaultProps.onListUpdated).toBeCalledWith([ + { field: 'process.args', operation: 'remove', update: 'allowReplacement' }, + { field: 'user.name', operation: 'remove', update: 'allowReplacement' }, + { field: 'process.args', operation: 'remove', update: 'defaultAllowReplacement' }, + { field: 'user.name', operation: 'remove', update: 'defaultAllowReplacement' }, + ]); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/bulk_actions/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/bulk_actions/index.tsx new file mode 100644 index 0000000000000..3511ee2118a89 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/bulk_actions/index.tsx @@ -0,0 +1,112 @@ +/* + * 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 { + EuiButtonEmpty, + EuiContextMenu, + EuiContextMenuPanelDescriptor, + EuiPopover, + EuiToolTip, + useGeneratedHtmlId, +} from '@elastic/eui'; + +import React, { useCallback, useMemo, useState } from 'react'; +import { getContextMenuPanels, PRIMARY_PANEL_ID } from '../get_context_menu_panels'; +import * as i18n from '../translations'; +import { BatchUpdateListItem, ContextEditorRow } from '../types'; + +export interface Props { + appliesTo: 'multipleRows' | 'singleRow'; + disabled: boolean; + disableAllow?: boolean; + disableAnonymize?: boolean; + disableDeny?: boolean; + disableUnanonymize?: boolean; + onListUpdated: (updates: BatchUpdateListItem[]) => void; + onlyDefaults: boolean; + selected: ContextEditorRow[]; +} + +const BulkActionsComponent: React.FC = ({ + appliesTo, + disabled, + disableAllow = false, + disableAnonymize = false, + disableDeny = false, + disableUnanonymize = false, + onListUpdated, + onlyDefaults, + selected, +}) => { + const [isPopoverOpen, setPopover] = useState(false); + + const contextMenuPopoverId = useGeneratedHtmlId({ + prefix: 'contextEditorBulkActions', + }); + + const closePopover = useCallback(() => setPopover(false), []); + + const onButtonClick = useCallback(() => setPopover((isOpen) => !isOpen), []); + + const button = useMemo( + () => ( + + + {appliesTo === 'multipleRows' ? i18n.BULK_ACTIONS : null} + + + ), + [appliesTo, disabled, onButtonClick] + ); + + const panels: EuiContextMenuPanelDescriptor[] = useMemo( + () => + getContextMenuPanels({ + disableAllow, + disableAnonymize, + disableDeny, + disableUnanonymize, + closePopover, + onListUpdated, + onlyDefaults, + selected, + }), + [ + closePopover, + disableAllow, + disableAnonymize, + disableDeny, + disableUnanonymize, + onListUpdated, + onlyDefaults, + selected, + ] + ); + + return ( + + + + ); +}; + +export const BulkActions = React.memo(BulkActionsComponent); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/get_columns/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/get_columns/index.test.tsx new file mode 100644 index 0000000000000..1f6590d576c30 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/get_columns/index.test.tsx @@ -0,0 +1,318 @@ +/* + * 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 { EuiBasicTableColumn } from '@elastic/eui'; +import { fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; + +import { TestProviders } from '../../../mock/test_providers/test_providers'; +import { ContextEditorRow } from '../types'; +import { getColumns } from '.'; + +interface ColumnWithRender { + render: (_: unknown, row: ContextEditorRow) => React.ReactNode; +} + +const fieldIsNotAllowed: ContextEditorRow = { + allowed: false, // the field is not allowed + anonymized: false, + denied: false, + field: 'event.category', + rawValues: ['authentication'], +}; + +const fieldIsAllowedButNotAnonymized: ContextEditorRow = { + allowed: true, // the field is allowed + anonymized: false, + denied: false, + field: 'event.category', + rawValues: ['authentication'], +}; + +const rowWhereFieldIsAnonymized: ContextEditorRow = { + allowed: true, + anonymized: true, // the field is anonymized + denied: false, + field: 'user.name', + rawValues: ['rawUsername'], +}; + +describe('getColumns', () => { + const onListUpdated = jest.fn(); + const rawData: Record = { + 'field.name': ['value1', 'value2'], + }; + + const row: ContextEditorRow = { + allowed: true, + anonymized: false, + denied: false, + field: 'event.category', + rawValues: ['authentication'], + }; + + it('includes the values column when rawData is NOT null', () => { + const columns: Array & { field?: string }> = getColumns({ + onListUpdated, + rawData, + }); + + expect(columns.some(({ field }) => field === 'rawValues')).toBe(true); + }); + + it('does NOT include the values column when rawData is null', () => { + const columns: Array & { field?: string }> = getColumns({ + onListUpdated, + rawData: null, + }); + + expect(columns.some(({ field }) => field === 'rawValues')).toBe(false); + }); + + describe('allowed column render()', () => { + it('calls onListUpdated with a `remove` operation when the toggle is clicked on field that is allowed', () => { + const columns = getColumns({ onListUpdated, rawData }); + const anonymizedColumn: ColumnWithRender = columns[0] as ColumnWithRender; + const allowedRow = { + ...row, + allowed: true, // the field is allowed + }; + + const { getByTestId } = render( + + <>{anonymizedColumn.render(undefined, allowedRow)} + + ); + + fireEvent.click(getByTestId('allowed')); + + expect(onListUpdated).toBeCalledWith([ + { field: 'event.category', operation: 'remove', update: 'allow' }, + ]); + }); + + it('calls onListUpdated with an `add` operation when the toggle is clicked on a field that is NOT allowed', () => { + const columns = getColumns({ onListUpdated, rawData }); + const anonymizedColumn: ColumnWithRender = columns[0] as ColumnWithRender; + const notAllowedRow = { + ...row, + allowed: false, // the field is NOT allowed + }; + + const { getByTestId } = render( + + <>{anonymizedColumn.render(undefined, notAllowedRow)} + + ); + + fireEvent.click(getByTestId('allowed')); + + expect(onListUpdated).toBeCalledWith([ + { field: 'event.category', operation: 'add', update: 'allow' }, + ]); + }); + + it('calls onListUpdated with a `remove` operation to update the `defaultAllowReplacement` list when the toggle is clicked on a default field that is allowed', () => { + const columns = getColumns({ onListUpdated, rawData: null }); // null raw data means the field is a default field + const anonymizedColumn: ColumnWithRender = columns[0] as ColumnWithRender; + const allowedRow = { + ...row, + allowed: true, // the field is allowed + }; + + const { getByTestId } = render( + + <>{anonymizedColumn.render(undefined, allowedRow)} + + ); + + fireEvent.click(getByTestId('allowed')); + + expect(onListUpdated).toBeCalledWith([ + { field: 'event.category', operation: 'remove', update: 'defaultAllowReplacement' }, + ]); + }); + }); + + describe('anonymized column render()', () => { + it('disables the button when the field is not allowed', () => { + const columns = getColumns({ onListUpdated, rawData }); + const anonymizedColumn: ColumnWithRender = columns[1] as ColumnWithRender; + + const { getByTestId } = render( + + <>{anonymizedColumn.render(undefined, fieldIsNotAllowed)} + + ); + + expect(getByTestId('anonymized')).toBeDisabled(); + }); + + it('enables the button when the field is allowed', () => { + const columns = getColumns({ onListUpdated, rawData }); + const anonymizedColumn: ColumnWithRender = columns[1] as ColumnWithRender; + + const { getByTestId } = render( + + <>{anonymizedColumn.render(undefined, fieldIsAllowedButNotAnonymized)} + + ); + + expect(getByTestId('anonymized')).not.toBeDisabled(); + }); + + it('calls onListUpdated with an `add` operation when an unanonymized field is toggled', () => { + const columns = getColumns({ onListUpdated, rawData }); + const anonymizedColumn: ColumnWithRender = columns[1] as ColumnWithRender; + + const { getByTestId } = render( + + <>{anonymizedColumn.render(undefined, row)} + + ); + + fireEvent.click(getByTestId('anonymized')); + + expect(onListUpdated).toBeCalledWith([ + { field: 'event.category', operation: 'add', update: 'allowReplacement' }, + ]); + }); + + it('calls onListUpdated with a `remove` operation when an anonymized field is toggled', () => { + const columns = getColumns({ onListUpdated, rawData }); + const anonymizedColumn: ColumnWithRender = columns[1] as ColumnWithRender; + + const anonymizedRow = { + ...row, + anonymized: true, + }; + + const { getByTestId } = render( + + <>{anonymizedColumn.render(undefined, anonymizedRow)} + + ); + + fireEvent.click(getByTestId('anonymized')); + + expect(onListUpdated).toBeCalledWith([ + { field: 'event.category', operation: 'remove', update: 'allowReplacement' }, + ]); + }); + + it('calls onListUpdated with an update to the `defaultAllowReplacement` list when rawData is null, because the field is a default', () => { + const columns = getColumns({ onListUpdated, rawData: null }); // null raw data means the field is a default field + const anonymizedColumn: ColumnWithRender = columns[1] as ColumnWithRender; + + const { getByTestId } = render( + + <>{anonymizedColumn.render(undefined, row)} + + ); + + fireEvent.click(getByTestId('anonymized')); + + expect(onListUpdated).toBeCalledWith([ + { field: 'event.category', operation: 'add', update: 'allowReplacement' }, + ]); + }); + + it('displays a closed eye icon when the field is anonymized', () => { + const columns = getColumns({ onListUpdated, rawData }); + const anonymizedColumn: ColumnWithRender = columns[1] as ColumnWithRender; + + const { container } = render( + + <>{anonymizedColumn.render(undefined, rowWhereFieldIsAnonymized)} + + ); + + expect(container.getElementsByClassName('euiButtonContent__icon')[0]).toHaveAttribute( + 'data-euiicon-type', + 'eyeClosed' + ); + }); + + it('displays a open eye icon when the field is NOT anonymized', () => { + const columns = getColumns({ onListUpdated, rawData }); + const anonymizedColumn: ColumnWithRender = columns[1] as ColumnWithRender; + + const { container } = render( + + <>{anonymizedColumn.render(undefined, fieldIsAllowedButNotAnonymized)} + + ); + + expect(container.getElementsByClassName('euiButtonContent__icon')[0]).toHaveAttribute( + 'data-euiicon-type', + 'eye' + ); + }); + + it('displays Yes when the field is anonymized', () => { + const columns = getColumns({ onListUpdated, rawData }); + const anonymizedColumn: ColumnWithRender = columns[1] as ColumnWithRender; + + const { getByTestId } = render( + + <>{anonymizedColumn.render(undefined, rowWhereFieldIsAnonymized)} + + ); + + expect(getByTestId('anonymized')).toHaveTextContent('Yes'); + }); + + it('displays No when the field is NOT anonymized', () => { + const columns = getColumns({ onListUpdated, rawData }); + const anonymizedColumn: ColumnWithRender = columns[1] as ColumnWithRender; + + const { getByTestId } = render( + + <>{anonymizedColumn.render(undefined, fieldIsAllowedButNotAnonymized)} + + ); + + expect(getByTestId('anonymized')).toHaveTextContent('No'); + }); + }); + + describe('values column render()', () => { + it('joins values with a comma', () => { + const columns = getColumns({ onListUpdated, rawData }); + const valuesColumn: ColumnWithRender = columns[3] as ColumnWithRender; + + const rowWithMultipleValues = { + ...row, + field: 'user.name', + rawValues: ['abe', 'bart'], + }; + + render( + + <>{valuesColumn.render(rowWithMultipleValues.rawValues, rowWithMultipleValues)} + + ); + + expect(screen.getByTestId('rawValues')).toHaveTextContent('abe,bart'); + }); + }); + + describe('actions column render()', () => { + it('renders the bulk actions', () => { + const columns = getColumns({ onListUpdated, rawData }); + const actionsColumn: ColumnWithRender = columns[4] as ColumnWithRender; + + render( + + <>{actionsColumn.render(null, row)} + + ); + + expect(screen.getByTestId('bulkActions')).toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/get_columns/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/get_columns/index.tsx new file mode 100644 index 0000000000000..75fd3e6d633eb --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/get_columns/index.tsx @@ -0,0 +1,132 @@ +/* + * 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 { EuiBasicTableColumn, EuiButtonEmpty, EuiCode, EuiSwitch, EuiText } from '@elastic/eui'; +import React from 'react'; +// eslint-disable-next-line @kbn/eslint/module_migration +import styled from 'styled-components'; + +import { BulkActions } from '../bulk_actions'; +import * as i18n from '../translations'; +import { BatchUpdateListItem, ContextEditorRow, FIELDS } from '../types'; + +const AnonymizedButton = styled(EuiButtonEmpty)` + max-height: 24px; +`; + +export const getColumns = ({ + onListUpdated, + rawData, +}: { + onListUpdated: (updates: BatchUpdateListItem[]) => void; + rawData: Record | null; +}): Array> => { + const actionsColumn: EuiBasicTableColumn = { + field: FIELDS.ACTIONS, + name: '', + render: (_, row) => { + return ( + + ); + }, + sortable: false, + width: '36px', + }; + + const valuesColumn: EuiBasicTableColumn = { + field: FIELDS.RAW_VALUES, + name: i18n.VALUES, + render: (rawValues: ContextEditorRow['rawValues']) => ( + {rawValues.join(',')} + ), + sortable: false, + }; + + const baseColumns: Array> = [ + { + field: FIELDS.ALLOWED, + name: i18n.ALLOWED, + render: (_, { allowed, field }) => ( + { + onListUpdated([ + { + field, + operation: allowed ? 'remove' : 'add', + update: rawData == null ? 'defaultAllow' : 'allow', + }, + ]); + + if (rawData == null && allowed) { + // when editing defaults, remove the default replacement if the field is no longer allowed + onListUpdated([ + { + field, + operation: 'remove', + update: 'defaultAllowReplacement', + }, + ]); + } + }} + /> + ), + sortable: true, + width: '75px', + }, + { + field: FIELDS.ANONYMIZED, + name: i18n.ANONYMIZED, + render: (_, { allowed, anonymized, field }) => ( + + onListUpdated([ + { + field, + operation: anonymized ? 'remove' : 'add', + update: rawData == null ? 'defaultAllowReplacement' : 'allowReplacement', + }, + ]) + } + > + {anonymized ? i18n.YES : i18n.NO} + + ), + sortable: true, + width: '102px', + }, + { + field: FIELDS.FIELD, + name: i18n.FIELD, + sortable: true, + width: '260px', + }, + ]; + + return rawData == null + ? [...baseColumns, actionsColumn] + : [...baseColumns, valuesColumn, actionsColumn]; +}; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/get_context_menu_panels/index.test.ts b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/get_context_menu_panels/index.test.ts new file mode 100644 index 0000000000000..e5cc77e1cd759 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/get_context_menu_panels/index.test.ts @@ -0,0 +1,677 @@ +/* + * 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 { getContextMenuPanels, PRIMARY_PANEL_ID, SECONDARY_PANEL_ID } from '.'; +import * as i18n from '../translations'; +import { ContextEditorRow } from '../types'; + +describe('getContextMenuPanels', () => { + const closePopover = jest.fn(); + const onListUpdated = jest.fn(); + const selected: ContextEditorRow[] = [ + { + allowed: true, + anonymized: true, + denied: false, + field: 'user.name', + rawValues: ['jodi'], + }, + ]; + + beforeEach(() => jest.clearAllMocks()); + + it('the first panel has a `primary-panel-id` when onlyDefaults is true', () => { + const onlyDefaults = true; + + const panels = getContextMenuPanels({ + disableAllow: false, + disableAnonymize: false, + disableDeny: false, + disableUnanonymize: false, + closePopover, + onListUpdated, + selected, + onlyDefaults, + }); + + expect(panels[0].id).toEqual(PRIMARY_PANEL_ID); + }); + + it('the first panel also has a `primary-panel-id` when onlyDefaults is false', () => { + const onlyDefaults = false; + + const panels = getContextMenuPanels({ + disableAllow: false, + disableAnonymize: false, + disableDeny: false, + disableUnanonymize: false, + closePopover, + onListUpdated, + selected, + onlyDefaults, + }); + + expect(panels[0].id).toEqual(PRIMARY_PANEL_ID); // first panel is always the primary panel + }); + + it('the second panel has a `secondary-panel-id` when onlyDefaults is false', () => { + const onlyDefaults = false; + + const panels = getContextMenuPanels({ + disableAllow: false, + disableAnonymize: false, + disableDeny: false, + disableUnanonymize: false, + closePopover, + onListUpdated, + selected, + onlyDefaults, + }); + + expect(panels[1].id).toEqual(SECONDARY_PANEL_ID); + }); + + it('the second panel is not rendered when onlyDefaults is true', () => { + const onlyDefaults = true; + + const panels = getContextMenuPanels({ + disableAllow: false, + disableAnonymize: false, + disableDeny: false, + disableUnanonymize: false, + closePopover, + onListUpdated, + selected, + onlyDefaults, + }); + + expect(panels.length).toEqual(1); + }); + + describe('allow by default', () => { + it('calls closePopover when allow by default is clicked', () => { + const panels = getContextMenuPanels({ + disableAllow: false, + disableAnonymize: false, + disableDeny: false, + disableUnanonymize: false, + closePopover, + onListUpdated, + selected, + onlyDefaults: false, + }); + + const allowByDefaultItem = panels[1].items?.find( + (item) => item.name === i18n.ALLOW_BY_DEFAULT + ); + + allowByDefaultItem?.onClick!( + new MouseEvent('click', { bubbles: true }) as unknown as React.MouseEvent< + HTMLHRElement, + MouseEvent + > + ); + + expect(closePopover).toHaveBeenCalled(); + }); + + it('calls onListUpdated to add the field to both the `allow` and `defaultAllow` lists', () => { + const panels = getContextMenuPanels({ + disableAllow: false, + disableAnonymize: false, + disableDeny: false, + disableUnanonymize: false, + closePopover, + onListUpdated, + selected, + onlyDefaults: false, + }); + + const allowByDefaultItem = panels[1].items?.find( + (item) => item.name === i18n.ALLOW_BY_DEFAULT + ); + + allowByDefaultItem?.onClick!( + new MouseEvent('click', { bubbles: true }) as unknown as React.MouseEvent< + HTMLHRElement, + MouseEvent + > + ); + + expect(onListUpdated).toHaveBeenCalledWith([ + { field: 'user.name', operation: 'add', update: 'allow' }, + { field: 'user.name', operation: 'add', update: 'defaultAllow' }, + ]); + }); + }); + + describe('deny by default', () => { + it('calls closePopover when deny by default is clicked', () => { + const panels = getContextMenuPanels({ + disableAllow: false, + disableAnonymize: false, + disableDeny: false, + disableUnanonymize: false, + closePopover, + onListUpdated, + selected, + onlyDefaults: false, + }); + + const denyByDefaultItem = panels[1].items?.find((item) => item.name === i18n.DENY_BY_DEFAULT); + + denyByDefaultItem?.onClick!( + new MouseEvent('click', { bubbles: true }) as unknown as React.MouseEvent< + HTMLHRElement, + MouseEvent + > + ); + + expect(closePopover).toHaveBeenCalled(); + }); + + it('calls onListUpdated to remove the field from both the `allow` and `defaultAllow` lists', () => { + const panels = getContextMenuPanels({ + disableAllow: false, + disableAnonymize: false, + disableDeny: false, + disableUnanonymize: false, + closePopover, + onListUpdated, + selected, + onlyDefaults: false, + }); + + const denyByDefaultItem = panels[1].items?.find((item) => item.name === i18n.DENY_BY_DEFAULT); + + denyByDefaultItem?.onClick!( + new MouseEvent('click', { bubbles: true }) as unknown as React.MouseEvent< + HTMLHRElement, + MouseEvent + > + ); + + expect(onListUpdated).toHaveBeenCalledWith([ + { field: 'user.name', operation: 'remove', update: 'allow' }, + { field: 'user.name', operation: 'remove', update: 'defaultAllow' }, + ]); + }); + }); + + describe('anonymize by default', () => { + it('calls closePopover when anonymize by default is clicked', () => { + const panels = getContextMenuPanels({ + disableAllow: false, + disableAnonymize: false, + disableDeny: false, + disableUnanonymize: false, + closePopover, + onListUpdated, + selected, + onlyDefaults: false, + }); + + const anonymizeByDefaultItem = panels[1].items?.find( + (item) => item.name === i18n.ANONYMIZE_BY_DEFAULT + ); + + anonymizeByDefaultItem?.onClick!( + new MouseEvent('click', { bubbles: true }) as unknown as React.MouseEvent< + HTMLHRElement, + MouseEvent + > + ); + + expect(closePopover).toHaveBeenCalled(); + }); + + it('calls onListUpdated to add the field to both the `allowReplacement` and `defaultAllowReplacement` lists', () => { + const panels = getContextMenuPanels({ + disableAllow: false, + disableAnonymize: false, + disableDeny: false, + disableUnanonymize: false, + closePopover, + onListUpdated, + selected, + onlyDefaults: false, + }); + + const anonymizeByDefaultItem = panels[1].items?.find( + (item) => item.name === i18n.ANONYMIZE_BY_DEFAULT + ); + + anonymizeByDefaultItem?.onClick!( + new MouseEvent('click', { bubbles: true }) as unknown as React.MouseEvent< + HTMLHRElement, + MouseEvent + > + ); + + expect(onListUpdated).toHaveBeenCalledWith([ + { field: 'user.name', operation: 'add', update: 'allowReplacement' }, + { field: 'user.name', operation: 'add', update: 'defaultAllowReplacement' }, + ]); + }); + }); + + describe('unanonymize by default', () => { + it('calls closePopover when unanonymize by default is clicked', () => { + const panels = getContextMenuPanels({ + disableAllow: false, + disableAnonymize: false, + disableDeny: false, + disableUnanonymize: false, + closePopover, + onListUpdated, + selected, + onlyDefaults: false, + }); + + const unAnonymizeByDefaultItem = panels[1].items?.find( + (item) => item.name === i18n.UNANONYMIZE_BY_DEFAULT + ); + + unAnonymizeByDefaultItem?.onClick!( + new MouseEvent('click', { bubbles: true }) as unknown as React.MouseEvent< + HTMLHRElement, + MouseEvent + > + ); + + expect(closePopover).toHaveBeenCalled(); + }); + + it('calls onListUpdated to remove the field from both the `allowReplacement` and `defaultAllowReplacement` lists', () => { + const panels = getContextMenuPanels({ + disableAllow: false, + disableAnonymize: false, + disableDeny: false, + disableUnanonymize: false, + closePopover, + onListUpdated, + selected, + onlyDefaults: false, + }); + + const unAnonymizeByDefaultItem = panels[1].items?.find( + (item) => item.name === i18n.UNANONYMIZE_BY_DEFAULT + ); + + unAnonymizeByDefaultItem?.onClick!( + new MouseEvent('click', { bubbles: true }) as unknown as React.MouseEvent< + HTMLHRElement, + MouseEvent + > + ); + + expect(onListUpdated).toHaveBeenCalledWith([ + { field: 'user.name', operation: 'remove', update: 'allowReplacement' }, + { field: 'user.name', operation: 'remove', update: 'defaultAllowReplacement' }, + ]); + }); + }); + + describe('allow', () => { + it('is disabled when `disableAlow` is true', () => { + const disableAllow = true; + + const panels = getContextMenuPanels({ + disableAllow, + disableAnonymize: false, + disableDeny: false, + disableUnanonymize: false, + closePopover, + onListUpdated, + selected, + onlyDefaults: false, + }); + + const allowItem = panels[0].items?.find((item) => item.name === i18n.ALLOW); + + expect(allowItem?.disabled).toBe(true); + }); + + it('is NOT disabled when `disableAlow` is false', () => { + const disableAllow = false; + + const panels = getContextMenuPanels({ + disableAllow, + disableAnonymize: false, + disableDeny: false, + disableUnanonymize: false, + closePopover, + onListUpdated, + selected, + onlyDefaults: false, + }); + + const allowItem = panels[0].items?.find((item) => item.name === i18n.ALLOW); + + expect(allowItem?.disabled).toBe(false); + }); + + it('calls closePopover when allow is clicked', () => { + const panels = getContextMenuPanels({ + disableAllow: false, + disableAnonymize: false, + disableDeny: false, + disableUnanonymize: false, + closePopover, + onListUpdated, + selected, + onlyDefaults: false, + }); + + const allowItem = panels[0].items?.find((item) => item.name === i18n.ALLOW); + + allowItem?.onClick!( + new MouseEvent('click', { bubbles: true }) as unknown as React.MouseEvent< + HTMLHRElement, + MouseEvent + > + ); + + expect(closePopover).toHaveBeenCalled(); + }); + + it('calls onListUpdated to add the field to the `allow` list', () => { + const panels = getContextMenuPanels({ + disableAllow: false, + disableAnonymize: false, + disableDeny: false, + disableUnanonymize: false, + closePopover, + onListUpdated, + selected, + onlyDefaults: false, + }); + + const allowItem = panels[0].items?.find((item) => item.name === i18n.ALLOW); + + allowItem?.onClick!( + new MouseEvent('click', { bubbles: true }) as unknown as React.MouseEvent< + HTMLHRElement, + MouseEvent + > + ); + + expect(onListUpdated).toHaveBeenCalledWith([ + { field: 'user.name', operation: 'add', update: 'allow' }, + ]); + }); + }); + + describe('deny', () => { + it('is disabled when `disableDeny` is true', () => { + const disableDeny = true; + + const panels = getContextMenuPanels({ + disableAllow: false, + disableAnonymize: false, + disableDeny, + disableUnanonymize: false, + closePopover, + onListUpdated, + selected, + onlyDefaults: false, + }); + + const denyItem = panels[0].items?.find((item) => item.name === i18n.DENY); + + expect(denyItem?.disabled).toBe(true); + }); + + it('is NOT disabled when `disableDeny` is false', () => { + const disableDeny = false; + + const panels = getContextMenuPanels({ + disableAllow: false, + disableAnonymize: false, + disableDeny, + disableUnanonymize: false, + closePopover, + onListUpdated, + selected, + onlyDefaults: false, + }); + + const denyItem = panels[0].items?.find((item) => item.name === i18n.DENY); + + expect(denyItem?.disabled).toBe(false); + }); + + it('calls closePopover when deny is clicked', () => { + const panels = getContextMenuPanels({ + disableAllow: false, + disableAnonymize: false, + disableDeny: false, + disableUnanonymize: false, + closePopover, + onListUpdated, + selected, + onlyDefaults: false, + }); + + const denyByDefaultItem = panels[0].items?.find((item) => item.name === i18n.DENY); + + denyByDefaultItem?.onClick!( + new MouseEvent('click', { bubbles: true }) as unknown as React.MouseEvent< + HTMLHRElement, + MouseEvent + > + ); + + expect(closePopover).toHaveBeenCalled(); + }); + + it('calls onListUpdated to remove the field from the `allow` list', () => { + const panels = getContextMenuPanels({ + disableAllow: false, + disableAnonymize: false, + disableDeny: false, + disableUnanonymize: false, + closePopover, + onListUpdated, + selected, + onlyDefaults: false, + }); + + const denyItem = panels[0].items?.find((item) => item.name === i18n.DENY); + + denyItem?.onClick!( + new MouseEvent('click', { bubbles: true }) as unknown as React.MouseEvent< + HTMLHRElement, + MouseEvent + > + ); + + expect(onListUpdated).toHaveBeenCalledWith([ + { field: 'user.name', operation: 'remove', update: 'allow' }, + ]); + }); + }); + + describe('anonymize', () => { + it('is disabled when `disableAnonymize` is true', () => { + const disableAnonymize = true; + + const panels = getContextMenuPanels({ + disableAllow: false, + disableAnonymize, + disableDeny: false, + disableUnanonymize: false, + closePopover, + onListUpdated, + selected, + onlyDefaults: false, + }); + + const anonymizeItem = panels[0].items?.find((item) => item.name === i18n.ANONYMIZE); + + expect(anonymizeItem?.disabled).toBe(true); + }); + + it('is NOT disabled when `disableAnonymize` is false', () => { + const disableAnonymize = false; + + const panels = getContextMenuPanels({ + disableAllow: false, + disableAnonymize, + disableDeny: false, + disableUnanonymize: false, + closePopover, + onListUpdated, + selected, + onlyDefaults: false, + }); + + const anonymizeItem = panels[0].items?.find((item) => item.name === i18n.ANONYMIZE); + + expect(anonymizeItem?.disabled).toBe(false); + }); + + it('calls closePopover when anonymize is clicked', () => { + const panels = getContextMenuPanels({ + disableAllow: false, + disableAnonymize: false, + disableDeny: false, + disableUnanonymize: false, + closePopover, + onListUpdated, + selected, + onlyDefaults: false, + }); + + const anonymizeItem = panels[0].items?.find((item) => item.name === i18n.ANONYMIZE); + + anonymizeItem?.onClick!( + new MouseEvent('click', { bubbles: true }) as unknown as React.MouseEvent< + HTMLHRElement, + MouseEvent + > + ); + + expect(closePopover).toHaveBeenCalled(); + }); + + it('calls onListUpdated to add the field to both the `allowReplacement` and `defaultAllowReplacement` lists', () => { + const panels = getContextMenuPanels({ + disableAllow: false, + disableAnonymize: false, + disableDeny: false, + disableUnanonymize: false, + closePopover, + onListUpdated, + selected, + onlyDefaults: false, + }); + + const anonymizeItem = panels[0].items?.find((item) => item.name === i18n.ANONYMIZE); + + anonymizeItem?.onClick!( + new MouseEvent('click', { bubbles: true }) as unknown as React.MouseEvent< + HTMLHRElement, + MouseEvent + > + ); + + expect(onListUpdated).toHaveBeenCalledWith([ + { field: 'user.name', operation: 'add', update: 'allowReplacement' }, + ]); + }); + }); + + describe('unanonymize', () => { + it('is disabled when `disableUnanonymize` is true', () => { + const disableUnanonymize = true; + + const panels = getContextMenuPanels({ + disableAllow: false, + disableAnonymize: false, + disableDeny: false, + disableUnanonymize, + closePopover, + onListUpdated, + selected, + onlyDefaults: false, + }); + + const unanonymizeItem = panels[0].items?.find((item) => item.name === i18n.UNANONYMIZE); + + expect(unanonymizeItem?.disabled).toBe(true); + }); + + it('is NOT disabled when `disableUnanonymize` is false', () => { + const disableUnanonymize = false; + + const panels = getContextMenuPanels({ + disableAllow: false, + disableAnonymize: false, + disableDeny: false, + disableUnanonymize, + closePopover, + onListUpdated, + selected, + onlyDefaults: false, + }); + + const unanonymizeItem = panels[0].items?.find((item) => item.name === i18n.UNANONYMIZE); + + expect(unanonymizeItem?.disabled).toBe(false); + }); + + it('calls closePopover when unanonymize is clicked', () => { + const panels = getContextMenuPanels({ + disableAllow: false, + disableAnonymize: false, + disableDeny: false, + disableUnanonymize: false, + closePopover, + onListUpdated, + selected, + onlyDefaults: false, + }); + + const unAnonymizeItem = panels[0].items?.find((item) => item.name === i18n.UNANONYMIZE); + + unAnonymizeItem?.onClick!( + new MouseEvent('click', { bubbles: true }) as unknown as React.MouseEvent< + HTMLHRElement, + MouseEvent + > + ); + + expect(closePopover).toHaveBeenCalled(); + }); + + it('calls onListUpdated to remove the field from the `allowReplacement` list', () => { + const panels = getContextMenuPanels({ + disableAllow: false, + disableAnonymize: false, + disableDeny: false, + disableUnanonymize: false, + closePopover, + onListUpdated, + selected, + onlyDefaults: false, + }); + + const unAnonymizeItem = panels[0].items?.find((item) => item.name === i18n.UNANONYMIZE); + + unAnonymizeItem?.onClick!( + new MouseEvent('click', { bubbles: true }) as unknown as React.MouseEvent< + HTMLHRElement, + MouseEvent + > + ); + + expect(onListUpdated).toHaveBeenCalledWith([ + { field: 'user.name', operation: 'remove', update: 'allowReplacement' }, + ]); + }); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/get_context_menu_panels/index.ts b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/get_context_menu_panels/index.ts new file mode 100644 index 0000000000000..a6afe0984d6e3 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/get_context_menu_panels/index.ts @@ -0,0 +1,223 @@ +/* + * 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 { EuiContextMenuPanelDescriptor } from '@elastic/eui'; + +import * as i18n from '../translations'; +import { BatchUpdateListItem, ContextEditorRow } from '../types'; + +export const PRIMARY_PANEL_ID = 'primary-panel-id'; +export const SECONDARY_PANEL_ID = 'secondary-panel-id'; + +export const getContextMenuPanels = ({ + disableAllow, + disableAnonymize, + disableDeny, + disableUnanonymize, + closePopover, + onListUpdated, + onlyDefaults, + selected, +}: { + disableAllow: boolean; + disableAnonymize: boolean; + disableDeny: boolean; + disableUnanonymize: boolean; + closePopover: () => void; + onListUpdated: (updates: BatchUpdateListItem[]) => void; + selected: ContextEditorRow[]; + onlyDefaults: boolean; +}): EuiContextMenuPanelDescriptor[] => { + const defaultsPanelId = onlyDefaults ? PRIMARY_PANEL_ID : SECONDARY_PANEL_ID; + const nonDefaultsPanelId = onlyDefaults ? SECONDARY_PANEL_ID : PRIMARY_PANEL_ID; + + const allowByDefault = [ + !onlyDefaults + ? { + icon: 'check', + name: i18n.ALLOW_BY_DEFAULT, + onClick: () => { + closePopover(); + + const updateAllow = selected.map(({ field }) => ({ + field, + operation: 'add', + update: 'allow', + })); + + const updateDefaultAllow = selected.map(({ field }) => ({ + field, + operation: 'add', + update: 'defaultAllow', + })); + + onListUpdated([...updateAllow, ...updateDefaultAllow]); + }, + } + : [], + ].flat(); + + const defaultsPanelItems: EuiContextMenuPanelDescriptor[] = [ + { + id: defaultsPanelId, + title: i18n.DEFAULTS, + items: [ + ...allowByDefault, + { + icon: 'cross', + name: i18n.DENY_BY_DEFAULT, + onClick: () => { + closePopover(); + + const updateAllow = selected.map(({ field }) => ({ + field, + operation: 'remove', + update: 'allow', + })); + + const updateDefaultAllow = selected.map(({ field }) => ({ + field, + operation: 'remove', + update: 'defaultAllow', + })); + + onListUpdated([...updateAllow, ...updateDefaultAllow]); + }, + }, + { + icon: 'eyeClosed', + name: i18n.ANONYMIZE_BY_DEFAULT, + onClick: () => { + closePopover(); + + const updateAllowReplacement = selected.map(({ field }) => ({ + field, + operation: 'add', + update: 'allowReplacement', + })); + + const updateDefaultAllowReplacement = selected.map( + ({ field }) => ({ + field, + operation: 'add', + update: 'defaultAllowReplacement', + }) + ); + + onListUpdated([...updateAllowReplacement, ...updateDefaultAllowReplacement]); + }, + }, + { + icon: 'eye', + name: i18n.UNANONYMIZE_BY_DEFAULT, + onClick: () => { + closePopover(); + + const updateAllowReplacement = selected.map(({ field }) => ({ + field, + operation: 'remove', + update: 'allowReplacement', + })); + + const updateDefaultAllowReplacement = selected.map( + ({ field }) => ({ + field, + operation: 'remove', + update: 'defaultAllowReplacement', + }) + ); + + onListUpdated([...updateAllowReplacement, ...updateDefaultAllowReplacement]); + }, + }, + ], + }, + ]; + + const nonDefaultsPanelItems: EuiContextMenuPanelDescriptor[] = [ + { + id: nonDefaultsPanelId, + items: [ + { + disabled: disableAllow, + icon: 'check', + name: i18n.ALLOW, + onClick: () => { + closePopover(); + + const updates = selected.map(({ field }) => ({ + field, + operation: 'add', + update: 'allow', + })); + + onListUpdated(updates); + }, + }, + { + disabled: disableDeny, + icon: 'cross', + name: i18n.DENY, + onClick: () => { + closePopover(); + + const updates = selected.map(({ field }) => ({ + field, + operation: 'remove', + update: 'allow', + })); + + onListUpdated(updates); + }, + }, + { + disabled: disableAnonymize, + icon: 'eyeClosed', + name: i18n.ANONYMIZE, + onClick: () => { + closePopover(); + + const updates = selected.map(({ field }) => ({ + field, + operation: 'add', + update: 'allowReplacement', + })); + + onListUpdated(updates); + }, + }, + { + disabled: disableUnanonymize, + icon: 'eye', + name: i18n.UNANONYMIZE, + onClick: () => { + closePopover(); + + const updates = selected.map(({ field }) => ({ + field, + operation: 'remove', + update: 'allowReplacement', + })); + + onListUpdated(updates); + }, + }, + { + isSeparator: true, + key: 'sep', + }, + { + name: i18n.DEFAULTS, + panel: defaultsPanelId, + }, + ], + }, + ...defaultsPanelItems, + ]; + + return onlyDefaults ? defaultsPanelItems : nonDefaultsPanelItems; +}; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/get_rows/index.test.ts b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/get_rows/index.test.ts new file mode 100644 index 0000000000000..c8c35767f1e54 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/get_rows/index.test.ts @@ -0,0 +1,95 @@ +/* + * 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 { SelectedPromptContext } from '../../../assistant/prompt_context/types'; +import { ContextEditorRow } from '../types'; +import { getRows } from '.'; + +describe('getRows', () => { + const defaultArgs: { + allow: SelectedPromptContext['allow']; + allowReplacement: SelectedPromptContext['allowReplacement']; + rawData: Record | null; + } = { + allow: ['event.action', 'user.name', 'other.field'], // other.field is not in the rawData + allowReplacement: ['user.name', 'host.ip'], // host.ip is not in the rawData + rawData: { + 'event.category': ['process'], // event.category is not in the allow list, nor is it in the allowReplacement list + 'event.action': ['process_stopped', 'stop'], // event.action is in the allow list, but not the allowReplacement list + 'user.name': ['max'], // user.name is in the allow list and the allowReplacement list + }, + }; + + it('returns only the allowed fields if no rawData is provided', () => { + const expected: ContextEditorRow[] = [ + { + allowed: true, + anonymized: false, + denied: false, + field: 'event.action', + rawValues: [], + }, + { + allowed: true, + anonymized: false, + denied: false, + field: 'other.field', + rawValues: [], + }, + { + allowed: true, + anonymized: true, + denied: false, + field: 'user.name', + rawValues: [], + }, + ]; + + const nullRawData: { + allow: SelectedPromptContext['allow']; + allowReplacement: SelectedPromptContext['allowReplacement']; + rawData: Record | null; + } = { + ...defaultArgs, + rawData: null, + }; + + const rows = getRows(nullRawData); + + expect(rows).toEqual(expected); + }); + + it('returns the expected metadata and raw values', () => { + const expected: ContextEditorRow[] = [ + { + allowed: true, + anonymized: false, + denied: false, + field: 'event.action', + rawValues: ['process_stopped', 'stop'], + }, + { + allowed: false, + anonymized: false, + denied: true, + field: 'event.category', + rawValues: ['process'], + }, + { + allowed: true, + anonymized: true, + denied: false, + field: 'user.name', + rawValues: ['max'], + }, + ]; + + const rows = getRows(defaultArgs); + + expect(rows).toEqual(expected); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/get_rows/index.ts b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/get_rows/index.ts new file mode 100644 index 0000000000000..279f75272372f --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/get_rows/index.ts @@ -0,0 +1,55 @@ +/* + * 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 { SelectedPromptContext } from '../../../assistant/prompt_context/types'; +import { isAllowed, isAnonymized, isDenied } from '../../helpers'; +import { ContextEditorRow } from '../types'; + +export const getRows = ({ + allow, + allowReplacement, + rawData, +}: { + allow: SelectedPromptContext['allow']; + allowReplacement: SelectedPromptContext['allowReplacement']; + rawData: Record | null; +}): ContextEditorRow[] => { + const allowReplacementSet = new Set(allowReplacement); + const allowSet = new Set(allow); + + if (rawData !== null && typeof rawData === 'object') { + const rawFields = Object.keys(rawData).sort(); + + return rawFields.reduce( + (acc, field) => [ + ...acc, + { + field, + allowed: isAllowed({ allowSet, field }), + anonymized: isAnonymized({ allowReplacementSet, field }), + denied: isDenied({ allowSet, field }), + rawValues: rawData[field], + }, + ], + [] + ); + } else { + return allow.sort().reduce( + (acc, field) => [ + ...acc, + { + field, + allowed: true, + anonymized: allowReplacementSet.has(field), + denied: false, + rawValues: [], + }, + ], + [] + ); + } +}; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/index.test.tsx new file mode 100644 index 0000000000000..77ffe90ec8c97 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/index.test.tsx @@ -0,0 +1,59 @@ +/* + * 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 { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { ContextEditor } from '.'; + +describe('ContextEditor', () => { + const allow = ['field1', 'field2']; + const allowReplacement = ['field1']; + const rawData = { field1: ['value1'], field2: ['value2'] }; + + const onListUpdated = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + + render( + + ); + }); + + it('renders the expected selected field count', () => { + expect(screen.getByTestId('selectedFields')).toHaveTextContent('Selected 0 fields'); + }); + + it('renders the select all fields button with the expected count', () => { + expect(screen.getByTestId('selectAllFields')).toHaveTextContent('Select all 2 fields'); + }); + + it('updates the table selection when "Select all n fields" is clicked', () => { + userEvent.click(screen.getByTestId('selectAllFields')); + + expect(screen.getByTestId('selectedFields')).toHaveTextContent('Selected 2 fields'); + }); + + it('calls onListUpdated with the expected values when the update button is clicked', () => { + userEvent.click(screen.getAllByTestId('allowed')[0]); + + expect(onListUpdated).toHaveBeenCalledWith([ + { + field: 'field1', + operation: 'remove', + update: 'allow', + }, + ]); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/index.tsx new file mode 100644 index 0000000000000..1aab52a0d6432 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/index.tsx @@ -0,0 +1,125 @@ +/* + * 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 { EuiInMemoryTable } from '@elastic/eui'; +import type { EuiSearchBarProps, EuiTableSelectionType } from '@elastic/eui'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; + +import { getColumns } from './get_columns'; +import { getRows } from './get_rows'; +import { Toolbar } from './toolbar'; +import * as i18n from './translations'; +import { BatchUpdateListItem, ContextEditorRow, FIELDS, SortConfig } from './types'; + +export const DEFAULT_PAGE_SIZE = 10; + +const pagination = { + initialPageSize: DEFAULT_PAGE_SIZE, + pageSizeOptions: [5, DEFAULT_PAGE_SIZE, 25, 50], +}; + +const defaultSort: SortConfig = { + sort: { + direction: 'desc', + field: FIELDS.ALLOWED, + }, +}; + +export interface Props { + allow: string[]; + allowReplacement: string[]; + onListUpdated: (updates: BatchUpdateListItem[]) => void; + rawData: Record | null; +} + +const search: EuiSearchBarProps = { + box: { + incremental: true, + }, + filters: [ + { + field: FIELDS.ALLOWED, + type: 'is', + name: i18n.ALLOWED, + }, + { + field: FIELDS.ANONYMIZED, + type: 'is', + name: i18n.ANONYMIZED, + }, + ], +}; + +const ContextEditorComponent: React.FC = ({ + allow, + allowReplacement, + onListUpdated, + rawData, +}) => { + const [selected, setSelection] = useState([]); + const selectionValue: EuiTableSelectionType = useMemo( + () => ({ + selectable: () => true, + onSelectionChange: (newSelection) => setSelection(newSelection), + initialSelected: [], + }), + [] + ); + const tableRef = useRef | null>(null); + + const columns = useMemo(() => getColumns({ onListUpdated, rawData }), [onListUpdated, rawData]); + + const rows = useMemo( + () => + getRows({ + allow, + allowReplacement, + rawData, + }), + [allow, allowReplacement, rawData] + ); + + const onSelectAll = useCallback(() => { + tableRef.current?.setSelection(rows); // updates selection in the EuiInMemoryTable + + setTimeout(() => setSelection(rows), 0); // updates selection in the component state + }, [rows]); + + const toolbar = useMemo( + () => ( + + ), + [onListUpdated, onSelectAll, rawData, rows.length, selected] + ); + + return ( + + ); +}; + +ContextEditorComponent.displayName = 'ContextEditorComponent'; +export const ContextEditor = React.memo(ContextEditorComponent); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/toolbar/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/toolbar/index.test.tsx new file mode 100644 index 0000000000000..11b2488c096ad --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/toolbar/index.test.tsx @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; + +import { Toolbar } from '.'; +import * as i18n from '../translations'; +import { ContextEditorRow } from '../types'; + +const selected: ContextEditorRow[] = [ + { + allowed: true, + anonymized: false, + denied: false, + field: 'event.action', + rawValues: ['process_stopped', 'stop'], + }, + { + allowed: false, + anonymized: false, + denied: true, + field: 'event.category', + rawValues: ['process'], + }, + { + allowed: true, + anonymized: true, + denied: false, + field: 'user.name', + rawValues: ['max'], + }, +]; + +describe('Toolbar', () => { + const defaultProps = { + onListUpdated: jest.fn(), + onlyDefaults: false, + onSelectAll: jest.fn(), + selected: [], // no rows selected + totalFields: 5, + }; + + it('displays the number of selected fields', () => { + const { getByText } = render(); + + const selectedCount = selected.length; + const selectedFieldsText = getByText(i18n.SELECTED_FIELDS(selectedCount)); + + expect(selectedFieldsText).toBeInTheDocument(); + }); + + it('disables bulk actions when no rows are selected', () => { + const { getByTestId } = render(); + + const bulkActionsButton = getByTestId('bulkActionsButton'); + + expect(bulkActionsButton).toBeDisabled(); + }); + + it('enables bulk actions when some fields are selected', () => { + const { getByTestId } = render(); + + const bulkActionsButton = getByTestId('bulkActionsButton'); + + expect(bulkActionsButton).not.toBeDisabled(); + }); + + it('calls onSelectAll when the Select All Fields button is clicked', () => { + const { getByText } = render(); + const selectAllButton = getByText(i18n.SELECT_ALL_FIELDS(defaultProps.totalFields)); + + fireEvent.click(selectAllButton); + + expect(defaultProps.onSelectAll).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/toolbar/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/toolbar/index.tsx new file mode 100644 index 0000000000000..476005c8da5ba --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/toolbar/index.tsx @@ -0,0 +1,62 @@ +/* + * 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 { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import React from 'react'; + +import { BulkActions } from '../bulk_actions'; +import * as i18n from '../translations'; +import { BatchUpdateListItem, ContextEditorRow } from '../types'; + +export interface Props { + onListUpdated: (updates: BatchUpdateListItem[]) => void; + onlyDefaults: boolean; + onSelectAll: () => void; + selected: ContextEditorRow[]; + totalFields: number; +} + +const ToolbarComponent: React.FC = ({ + onListUpdated, + onlyDefaults, + onSelectAll, + selected, + totalFields, +}) => ( + + + + {i18n.SELECTED_FIELDS(selected.length)} + + + + + + {i18n.SELECT_ALL_FIELDS(totalFields)} + + + + + + + +); + +ToolbarComponent.displayName = 'ToolbarComponent'; + +export const Toolbar = React.memo(ToolbarComponent); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/translations.ts new file mode 100644 index 0000000000000..a48a52ee8092a --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/translations.ts @@ -0,0 +1,146 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export const ALL_ACTIONS = i18n.translate( + 'xpack.elasticAssistant.assistant.dataAnonymizationEditor.contextEditor.allActionsTooltip', + { + defaultMessage: 'All actions', + } +); + +export const ALLOW = i18n.translate( + 'xpack.elasticAssistant.assistant.dataAnonymizationEditor.contextEditor.allowAction', + { + defaultMessage: 'Allow', + } +); + +export const ALLOW_BY_DEFAULT = i18n.translate( + 'xpack.elasticAssistant.assistant.dataAnonymizationEditor.contextEditor.allowByDefaultAction', + { + defaultMessage: 'Allow by default', + } +); + +export const ALLOWED = i18n.translate( + 'xpack.elasticAssistant.assistant.dataAnonymizationEditor.contextEditor.allowedColumnTitle', + { + defaultMessage: 'Allowed', + } +); + +export const ALWAYS = i18n.translate( + 'xpack.elasticAssistant.assistant.dataAnonymizationEditor.contextEditor.alwaysSubmenu', + { + defaultMessage: 'Always', + } +); + +export const ANONYMIZE = i18n.translate( + 'xpack.elasticAssistant.assistant.dataAnonymizationEditor.contextEditor.anonymizeAction', + { + defaultMessage: 'Anonymize', + } +); + +export const ANONYMIZE_BY_DEFAULT = i18n.translate( + 'xpack.elasticAssistant.assistant.dataAnonymizationEditor.contextEditor.anonymizeByDefaultAction', + { + defaultMessage: 'Anonymize by default', + } +); + +export const ANONYMIZED = i18n.translate( + 'xpack.elasticAssistant.assistant.dataAnonymizationEditor.contextEditor.anonymizedColumnTitle', + { + defaultMessage: 'Anonymized', + } +); + +export const BULK_ACTIONS = i18n.translate( + 'xpack.elasticAssistant.assistant.dataAnonymizationEditor.contextEditor.bulkActions', + { + defaultMessage: 'Bulk actions', + } +); + +export const DEFAULTS = i18n.translate( + 'xpack.elasticAssistant.assistant.dataAnonymizationEditor.contextEditor.defaultsSubmenu', + { + defaultMessage: 'Defaults', + } +); + +export const DENY = i18n.translate( + 'xpack.elasticAssistant.assistant.dataAnonymizationEditor.contextEditor.denyAction', + { + defaultMessage: 'Deny', + } +); + +export const DENY_BY_DEFAULT = i18n.translate( + 'xpack.elasticAssistant.assistant.dataAnonymizationEditor.contextEditor.denyByDefaultAction', + { + defaultMessage: 'Deny by default', + } +); + +export const FIELD = i18n.translate( + 'xpack.elasticAssistant.assistant.dataAnonymizationEditor.contextEditor.fieldColumnTitle', + { + defaultMessage: 'Field', + } +); + +export const NO = i18n.translate( + 'xpack.elasticAssistant.assistant.dataAnonymizationEditor.contextEditor.noButtonLabel', + { + defaultMessage: 'No', + } +); + +export const SELECT_ALL_FIELDS = (totalFields: number) => + i18n.translate('xpack.elasticAssistant.dataAnonymizationEditor.contextEditor.selectAllFields', { + values: { totalFields }, + defaultMessage: 'Select all {totalFields} fields', + }); + +export const SELECTED_FIELDS = (selected: number) => + i18n.translate('xpack.elasticAssistant.dataAnonymizationEditor.contextEditor.selectedFields', { + values: { selected }, + defaultMessage: 'Selected {selected} fields', + }); + +export const UNANONYMIZE = i18n.translate( + 'xpack.elasticAssistant.assistant.dataAnonymizationEditor.contextEditor.unanonymizeAction', + { + defaultMessage: 'Unanonymize', + } +); + +export const UNANONYMIZE_BY_DEFAULT = i18n.translate( + 'xpack.elasticAssistant.assistant.dataAnonymizationEditor.contextEditor.unanonymizeByDefaultAction', + { + defaultMessage: 'Unanonymize by default', + } +); + +export const VALUES = i18n.translate( + 'xpack.elasticAssistant.assistant.dataAnonymizationEditor.contextEditor.valuesColumnTitle', + { + defaultMessage: 'Values', + } +); + +export const YES = i18n.translate( + 'xpack.elasticAssistant.assistant.dataAnonymizationEditor.contextEditor.yesButtonLabel', + { + defaultMessage: 'Yes', + } +); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/types.ts b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/types.ts new file mode 100644 index 0000000000000..251c41db0396d --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/types.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Direction } from '@elastic/eui'; + +export interface ContextEditorRow { + /** Is the field is allowed to be included in the context sent to the assistant */ + allowed: boolean; + /** Are the field's values anonymized */ + anonymized: boolean; + /** Is the field is denied to be included in the context sent to the assistant */ + denied: boolean; + /** The name of the field, e.g. `user.name` */ + field: string; + /** The raw, NOT anonymized values */ + rawValues: string[]; +} + +export const FIELDS = { + ACTIONS: 'actions', + ALLOWED: 'allowed', + ANONYMIZED: 'anonymized', + DENIED: 'denied', + FIELD: 'field', + RAW_VALUES: 'rawValues', +}; + +export interface SortConfig { + sort: { + direction: Direction; + field: string; + }; +} + +/** The `field` in the specified `list` will be added or removed, as specified by the `operation` */ +export interface BatchUpdateListItem { + field: string; + operation: 'add' | 'remove'; + update: + | 'allow' + | 'allowReplacement' + | 'defaultAllow' + | 'defaultAllowReplacement' + | 'deny' + | 'denyReplacement'; +} diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/get_stats/index.test.ts b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/get_stats/index.test.ts new file mode 100644 index 0000000000000..94d96cbbec685 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/get_stats/index.test.ts @@ -0,0 +1,53 @@ +/* + * 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 { SelectedPromptContext } from '../../assistant/prompt_context/types'; +import type { Stats } from '../helpers'; +import { getStats } from '.'; + +describe('getStats', () => { + it('returns ZERO_STATS for string rawData', () => { + const context: SelectedPromptContext = { + allow: [], + allowReplacement: [], + promptContextId: 'abcd', + rawData: 'this will not be anonymized', + }; + + const expectedResult: Stats = { + allowed: 0, + anonymized: 0, + denied: 0, + total: 0, + }; + + expect(getStats(context)).toEqual(expectedResult); + }); + + it('returns the expected stats for object rawData', () => { + const context: SelectedPromptContext = { + allow: ['event.category', 'event.action', 'user.name'], + allowReplacement: ['user.name', 'host.ip'], // only user.name is allowed to be sent + promptContextId: 'abcd', + rawData: { + 'event.category': ['process'], + 'event.action': ['process_stopped'], + 'user.name': ['sean'], + other: ['this', 'is', 'not', 'allowed'], + }, + }; + + const expectedResult: Stats = { + allowed: 3, + anonymized: 1, + denied: 1, + total: 4, + }; + + expect(getStats(context)).toEqual(expectedResult); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/get_stats/index.ts b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/get_stats/index.ts new file mode 100644 index 0000000000000..fbed27e5ac740 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/get_stats/index.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { SelectedPromptContext } from '../../assistant/prompt_context/types'; +import { Stats, isAllowed, isAnonymized, isDenied } from '../helpers'; + +export const getStats = ({ allow, allowReplacement, rawData }: SelectedPromptContext): Stats => { + const ZERO_STATS = { + allowed: 0, + anonymized: 0, + denied: 0, + total: 0, + }; + + if (typeof rawData === 'string') { + return ZERO_STATS; + } else { + const rawFields = Object.keys(rawData); + + const allowReplacementSet = new Set(allowReplacement); + const allowSet = new Set(allow); + + return rawFields.reduce( + (acc, field) => ({ + allowed: acc.allowed + (isAllowed({ allowSet, field }) ? 1 : 0), + anonymized: + acc.anonymized + + (isAllowed({ allowSet, field }) && isAnonymized({ allowReplacementSet, field }) ? 1 : 0), + denied: acc.denied + (isDenied({ allowSet, field }) ? 1 : 0), + total: acc.total + 1, + }), + ZERO_STATS + ); + } +}; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/helpers/index.test.ts b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/helpers/index.test.ts new file mode 100644 index 0000000000000..77031bccdf8ae --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/helpers/index.test.ts @@ -0,0 +1,304 @@ +/* + * 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 { SelectedPromptContext } from '../../assistant/prompt_context/types'; +import { + isAllowed, + isAnonymized, + isDenied, + getIsDataAnonymizable, + updateDefaultList, + updateDefaults, + updateList, + updateSelectedPromptContext, +} from '.'; +import { BatchUpdateListItem } from '../context_editor/types'; + +describe('helpers', () => { + beforeEach(() => jest.clearAllMocks()); + + describe('getIsDataAnonymizable', () => { + it('returns false for string data', () => { + const rawData = 'this will not be anonymized'; + + const result = getIsDataAnonymizable(rawData); + + expect(result).toBe(false); + }); + + it('returns true for key / values data', () => { + const rawData = { key: ['value1', 'value2'] }; + + const result = getIsDataAnonymizable(rawData); + + expect(result).toBe(true); + }); + }); + + describe('isAllowed', () => { + it('returns true when the field is present in the allowSet', () => { + const allowSet = new Set(['fieldName1', 'fieldName2', 'fieldName3']); + + expect(isAllowed({ allowSet, field: 'fieldName1' })).toBe(true); + }); + + it('returns false when the field is NOT present in the allowSet', () => { + const allowSet = new Set(['fieldName1', 'fieldName2', 'fieldName3']); + + expect(isAllowed({ allowSet, field: 'nonexistentField' })).toBe(false); + }); + }); + + describe('isDenied', () => { + it('returns true when the field is NOT in the allowSet', () => { + const allowSet = new Set(['field1', 'field2']); + const field = 'field3'; + + expect(isDenied({ allowSet, field })).toBe(true); + }); + + it('returns false when the field is in the allowSet', () => { + const allowSet = new Set(['field1', 'field2']); + const field = 'field1'; + + expect(isDenied({ allowSet, field })).toBe(false); + }); + + it('returns true for an empty allowSet', () => { + const allowSet = new Set(); + const field = 'field1'; + + expect(isDenied({ allowSet, field })).toBe(true); + }); + + it('returns false when the field is an empty string and allowSet contains the empty string', () => { + const allowSet = new Set(['', 'field1']); + const field = ''; + + expect(isDenied({ allowSet, field })).toBe(false); + }); + }); + + describe('isAnonymized', () => { + const allowReplacementSet = new Set(['user.name', 'host.name']); + + it('returns true when the field is in the allowReplacementSet', () => { + const field = 'user.name'; + + expect(isAnonymized({ allowReplacementSet, field })).toBe(true); + }); + + it('returns false when the field is NOT in the allowReplacementSet', () => { + const field = 'foozle'; + + expect(isAnonymized({ allowReplacementSet, field })).toBe(false); + }); + + it('returns false when allowReplacementSet is empty', () => { + const emptySet = new Set(); + const field = 'user.name'; + + expect(isAnonymized({ allowReplacementSet: emptySet, field })).toBe(false); + }); + }); + + describe('updateList', () => { + it('adds a new field to the list when the operation is `add`', () => { + const result = updateList({ + field: 'newField', + list: ['field1', 'field2'], + operation: 'add', + }); + + expect(result).toEqual(['field1', 'field2', 'newField']); + }); + + it('does NOT add a duplicate field to the list when the operation is `add`', () => { + const result = updateList({ + field: 'field1', + list: ['field1', 'field2'], + operation: 'add', + }); + + expect(result).toEqual(['field1', 'field2']); + }); + + it('removes an existing field from the list when the operation is `remove`', () => { + const result = updateList({ + field: 'field1', + list: ['field1', 'field2'], + operation: 'remove', + }); + + expect(result).toEqual(['field2']); + }); + + it('should NOT modify the list when removing a non-existent field', () => { + const result = updateList({ + field: 'host.name', + list: ['field1', 'field2'], + operation: 'remove', + }); + + expect(result).toEqual(['field1', 'field2']); + }); + }); + + describe('updateSelectedPromptContext', () => { + const selectedPromptContext: SelectedPromptContext = { + allow: ['user.name', 'event.category'], + allowReplacement: ['user.name'], + promptContextId: 'testId', + rawData: {}, + }; + + it('updates the allow list when update is `allow` and the operation is `add`', () => { + const result = updateSelectedPromptContext({ + field: 'event.action', + operation: 'add', + selectedPromptContext, + update: 'allow', + }); + + expect(result.allow).toEqual(['user.name', 'event.category', 'event.action']); + }); + + it('updates the allow list when update is `allow` and the operation is `remove`', () => { + const result = updateSelectedPromptContext({ + field: 'user.name', + operation: 'remove', + selectedPromptContext, + update: 'allow', + }); + + expect(result.allow).toEqual(['event.category']); + }); + + it('updates the allowReplacement list when update is `allowReplacement` and the operation is `add`', () => { + const result = updateSelectedPromptContext({ + field: 'event.type', + operation: 'add', + selectedPromptContext, + update: 'allowReplacement', + }); + expect(result.allowReplacement).toEqual(['user.name', 'event.type']); + }); + + it('updates the allowReplacement list when update is `allowReplacement` and the operation is `remove`', () => { + const result = updateSelectedPromptContext({ + field: 'user.name', + operation: 'remove', + selectedPromptContext, + update: 'allowReplacement', + }); + expect(result.allowReplacement).toEqual([]); + }); + + it('does not update selectedPromptContext when update is not "allow" or "allowReplacement"', () => { + const result = updateSelectedPromptContext({ + field: 'user.name', + operation: 'add', + selectedPromptContext, + update: 'deny', + }); + + expect(result).toEqual(selectedPromptContext); + }); + }); + + describe('updateDefaultList', () => { + it('updates the `defaultAllow` list to add a field when the operation is add', () => { + const currentList = ['test1', 'test2']; + const setDefaultList = jest.fn(); + const update = 'defaultAllow'; + const updates: BatchUpdateListItem[] = [{ field: 'test3', operation: 'add', update }]; + + updateDefaultList({ currentList, setDefaultList, update, updates }); + + expect(setDefaultList).toBeCalledWith([...currentList, 'test3']); + }); + + it('updates the `defaultAllow` list to remove a field when the operation is remove', () => { + const currentList = ['test1', 'test2']; + const setDefaultList = jest.fn(); + const update = 'defaultAllow'; + const updates: BatchUpdateListItem[] = [{ field: 'test1', operation: 'remove', update }]; + + updateDefaultList({ currentList, setDefaultList, update, updates }); + + expect(setDefaultList).toBeCalledWith(['test2']); + }); + + it('does NOT invoke `setDefaultList` when `update` does NOT match any of the batched `updates` types', () => { + const currentList = ['test1', 'test2']; + const setDefaultList = jest.fn(); + const update = 'allow'; + const updates: BatchUpdateListItem[] = [ + { field: 'test1', operation: 'remove', update: 'defaultAllow' }, // update does not match + ]; + + updateDefaultList({ currentList, setDefaultList, update, updates }); + + expect(setDefaultList).not.toBeCalled(); + }); + + it('does NOT invoke `setDefaultList` when `updates` is empty', () => { + const currentList = ['test1', 'test2']; + const setDefaultList = jest.fn(); + const update = 'defaultAllow'; + const updates: BatchUpdateListItem[] = []; // no updates + + updateDefaultList({ currentList, setDefaultList, update, updates }); + + expect(setDefaultList).not.toBeCalled(); + }); + }); + + describe('updateDefaults', () => { + const setDefaultAllow = jest.fn(); + const setDefaultAllowReplacement = jest.fn(); + + const defaultAllow = ['field1', 'field2']; + const defaultAllowReplacement = ['field2']; + const batchUpdateListItems: BatchUpdateListItem[] = [ + { + field: 'field1', + operation: 'remove', + update: 'defaultAllow', + }, + { + field: 'host.name', + operation: 'add', + update: 'defaultAllowReplacement', + }, + ]; + + it('updates defaultAllow with filtered updates', () => { + updateDefaults({ + defaultAllow, + defaultAllowReplacement, + setDefaultAllow, + setDefaultAllowReplacement, + updates: batchUpdateListItems, + }); + + expect(setDefaultAllow).toHaveBeenCalledWith(['field2']); + }); + + it('updates defaultAllowReplacement with filtered updates', () => { + updateDefaults({ + defaultAllow, + defaultAllowReplacement, + setDefaultAllow, + setDefaultAllowReplacement, + updates: batchUpdateListItems, + }); + + expect(setDefaultAllowReplacement).toHaveBeenCalledWith(['field2', 'host.name']); + }); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/helpers/index.ts b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/helpers/index.ts new file mode 100644 index 0000000000000..389ba9ce421b7 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/helpers/index.ts @@ -0,0 +1,135 @@ +/* + * 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 { SelectedPromptContext } from '../../assistant/prompt_context/types'; +import type { BatchUpdateListItem } from '../context_editor/types'; + +export const getIsDataAnonymizable = (rawData: string | Record): boolean => + typeof rawData !== 'string'; + +export interface Stats { + allowed: number; + anonymized: number; + denied: number; + total: number; +} + +export const isAllowed = ({ allowSet, field }: { allowSet: Set; field: string }): boolean => + allowSet.has(field); + +export const isDenied = ({ allowSet, field }: { allowSet: Set; field: string }): boolean => + !allowSet.has(field); + +export const isAnonymized = ({ + allowReplacementSet, + field, +}: { + allowReplacementSet: Set; + field: string; +}): boolean => allowReplacementSet.has(field); + +export const updateList = ({ + field, + list, + operation, +}: { + field: string; + list: string[]; + operation: 'add' | 'remove'; +}): string[] => { + if (operation === 'add') { + return list.includes(field) ? list : [...list, field]; + } else { + return list.filter((x) => x !== field); + } +}; + +export const updateSelectedPromptContext = ({ + field, + operation, + selectedPromptContext, + update, +}: { + field: string; + operation: 'add' | 'remove'; + selectedPromptContext: SelectedPromptContext; + update: + | 'allow' + | 'allowReplacement' + | 'defaultAllow' + | 'defaultAllowReplacement' + | 'deny' + | 'denyReplacement'; +}): SelectedPromptContext => { + const { allow, allowReplacement } = selectedPromptContext; + + switch (update) { + case 'allow': + return { + ...selectedPromptContext, + allow: updateList({ field, list: allow, operation }), + }; + case 'allowReplacement': + return { + ...selectedPromptContext, + allowReplacement: updateList({ field, list: allowReplacement, operation }), + }; + default: + return selectedPromptContext; + } +}; + +export const updateDefaultList = ({ + currentList, + setDefaultList, + update, + updates, +}: { + currentList: string[]; + setDefaultList: React.Dispatch>; + update: 'allow' | 'allowReplacement' | 'defaultAllow' | 'defaultAllowReplacement' | 'deny'; + updates: BatchUpdateListItem[]; +}): void => { + const filteredUpdates = updates.filter((x) => x.update === update); + + if (filteredUpdates.length > 0) { + const updatedList = filteredUpdates.reduce( + (acc, { field, operation }) => updateList({ field, list: acc, operation }), + currentList + ); + + setDefaultList(updatedList); + } +}; + +export const updateDefaults = ({ + defaultAllow, + defaultAllowReplacement, + setDefaultAllow, + setDefaultAllowReplacement, + updates, +}: { + defaultAllow: string[]; + defaultAllowReplacement: string[]; + setDefaultAllow: React.Dispatch>; + setDefaultAllowReplacement: React.Dispatch>; + updates: BatchUpdateListItem[]; +}): void => { + updateDefaultList({ + currentList: defaultAllow, + setDefaultList: setDefaultAllow, + update: 'defaultAllow', + updates, + }); + + updateDefaultList({ + currentList: defaultAllowReplacement, + setDefaultList: setDefaultAllowReplacement, + update: 'defaultAllowReplacement', + updates, + }); +}; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/index.test.tsx new file mode 100644 index 0000000000000..84a36b3fb8454 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/index.test.tsx @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { SelectedPromptContext } from '../assistant/prompt_context/types'; +import { TestProviders } from '../mock/test_providers/test_providers'; +import { DataAnonymizationEditor } from '.'; + +describe('DataAnonymizationEditor', () => { + const mockSelectedPromptContext: SelectedPromptContext = { + allow: ['field1', 'field2'], + allowReplacement: ['field1'], + promptContextId: 'test-id', + rawData: 'test-raw-data', + }; + + it('renders stats', () => { + render( + + + + ); + + expect(screen.getByTestId('stats')).toBeInTheDocument(); + }); + + describe('when rawData is a string (non-anonymized data)', () => { + it('renders the ReadOnlyContextViewer when rawData is (non-anonymized data)', () => { + render( + + + + ); + + expect(screen.getByTestId('readOnlyContextViewer')).toBeInTheDocument(); + }); + + it('does NOT render the ContextEditor when rawData is non-anonymized data', () => { + render( + + + + ); + + expect(screen.queryByTestId('contextEditor')).not.toBeInTheDocument(); + }); + }); + + describe('when rawData is a `Record` (anonymized data)', () => { + const setSelectedPromptContexts = jest.fn(); + const mockRawData: Record = { + field1: ['value1', 'value2'], + field2: ['value3', 'value4', 'value5'], + field3: ['value6'], + }; + + const selectedPromptContextWithAnonymized: SelectedPromptContext = { + ...mockSelectedPromptContext, + rawData: mockRawData, + }; + + beforeEach(() => { + render( + + + + ); + }); + + it('renders the ContextEditor when rawData is anonymized data', () => { + expect(screen.getByTestId('contextEditor')).toBeInTheDocument(); + }); + + it('does NOT render the ReadOnlyContextViewer when rawData is anonymized data', () => { + expect(screen.queryByTestId('readOnlyContextViewer')).not.toBeInTheDocument(); + }); + + it('calls setSelectedPromptContexts when a field is toggled', () => { + userEvent.click(screen.getAllByTestId('allowed')[0]); // toggle the first field + + expect(setSelectedPromptContexts).toBeCalled(); + }); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/index.tsx new file mode 100644 index 0000000000000..089e316f4795f --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/index.tsx @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiSpacer } from '@elastic/eui'; +import React, { useCallback, useMemo } from 'react'; +// eslint-disable-next-line @kbn/eslint/module_migration +import styled from 'styled-components'; + +import { useAssistantContext } from '../assistant_context'; +import type { SelectedPromptContext } from '../assistant/prompt_context/types'; +import { ContextEditor } from './context_editor'; +import { BatchUpdateListItem } from './context_editor/types'; +import { getIsDataAnonymizable, updateDefaults, updateSelectedPromptContext } from './helpers'; +import { ReadOnlyContextViewer } from './read_only_context_viewer'; +import { Stats } from './stats'; + +const EditorContainer = styled.div` + overflow-x: auto; +`; + +export interface Props { + selectedPromptContext: SelectedPromptContext; + setSelectedPromptContexts: React.Dispatch< + React.SetStateAction> + >; +} + +const DataAnonymizationEditorComponent: React.FC = ({ + selectedPromptContext, + setSelectedPromptContexts, +}) => { + const { defaultAllow, defaultAllowReplacement, setDefaultAllow, setDefaultAllowReplacement } = + useAssistantContext(); + const isDataAnonymizable = useMemo( + () => getIsDataAnonymizable(selectedPromptContext.rawData), + [selectedPromptContext] + ); + + const onListUpdated = useCallback( + (updates: BatchUpdateListItem[]) => { + const updatedPromptContext = updates.reduce( + (acc, { field, operation, update }) => + updateSelectedPromptContext({ + field, + operation, + selectedPromptContext: acc, + update, + }), + selectedPromptContext + ); + + setSelectedPromptContexts((prev) => ({ + ...prev, + [selectedPromptContext.promptContextId]: updatedPromptContext, + })); + + updateDefaults({ + defaultAllow, + defaultAllowReplacement, + setDefaultAllow, + setDefaultAllowReplacement, + updates, + }); + }, + [ + defaultAllow, + defaultAllowReplacement, + selectedPromptContext, + setDefaultAllow, + setDefaultAllowReplacement, + setSelectedPromptContexts, + ] + ); + + return ( + + + + + + {typeof selectedPromptContext.rawData === 'string' ? ( + + ) : ( + + )} + + ); +}; + +export const DataAnonymizationEditor = React.memo(DataAnonymizationEditorComponent); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/read_only_context_viewer/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/read_only_context_viewer/index.test.tsx new file mode 100644 index 0000000000000..101b5058b5481 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/read_only_context_viewer/index.test.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; + +import { SYSTEM_PROMPT_CONTEXT_NON_I18N } from '../../content/prompts/system/translations'; +import { ReadOnlyContextViewer, Props } from '.'; + +const defaultProps: Props = { + rawData: 'this content is NOT anonymized', +}; + +describe('ReadOnlyContextViewer', () => { + it('renders the context with the correct formatting', () => { + render(); + + const contextBlock = screen.getByTestId('readOnlyContextViewer'); + + expect(contextBlock.textContent).toBe(SYSTEM_PROMPT_CONTEXT_NON_I18N(defaultProps.rawData)); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/read_only_context_viewer/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/read_only_context_viewer/index.tsx new file mode 100644 index 0000000000000..0fd2da1942bc5 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/read_only_context_viewer/index.tsx @@ -0,0 +1,27 @@ +/* + * 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 { EuiCodeBlock } from '@elastic/eui'; +import React from 'react'; + +import { SYSTEM_PROMPT_CONTEXT_NON_I18N } from '../../content/prompts/system/translations'; + +export interface Props { + rawData: string; +} + +const ReadOnlyContextViewerComponent: React.FC = ({ rawData }) => { + return ( + + {SYSTEM_PROMPT_CONTEXT_NON_I18N(rawData)} + + ); +}; + +ReadOnlyContextViewerComponent.displayName = 'ReadOnlyContextViewerComponent'; + +export const ReadOnlyContextViewer = React.memo(ReadOnlyContextViewerComponent); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/stats/allowed_stat/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/stats/allowed_stat/index.test.tsx new file mode 100644 index 0000000000000..8f5b3506c5054 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/stats/allowed_stat/index.test.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { AllowedStat } from '.'; +import * as i18n from './translations'; + +describe('AllowedStat', () => { + const defaultProps = { + allowed: 3, + total: 5, + }; + + it('renders the expected stat content', () => { + render(); + + expect(screen.getByTestId('allowedStat')).toHaveTextContent('3Allowed'); + }); + + it('displays the correct tooltip content', async () => { + render(); + + userEvent.hover(screen.getByTestId('allowedStat')); + + await waitFor(() => { + expect(screen.getByText(i18n.ALLOWED_TOOLTIP(defaultProps))).toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/stats/allowed_stat/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/stats/allowed_stat/index.tsx new file mode 100644 index 0000000000000..096617d17422a --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/stats/allowed_stat/index.tsx @@ -0,0 +1,37 @@ +/* + * 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 { EuiStat, EuiToolTip } from '@elastic/eui'; +import React, { useMemo } from 'react'; + +import { TITLE_SIZE } from '../constants'; +import * as i18n from './translations'; + +interface Props { + allowed: number; + total: number; +} + +const AllowedStatComponent: React.FC = ({ allowed, total }) => { + const tooltipContent = useMemo(() => i18n.ALLOWED_TOOLTIP({ allowed, total }), [allowed, total]); + + return ( + + + + ); +}; + +AllowedStatComponent.displayName = 'AllowedStatComponent'; + +export const AllowedStat = React.memo(AllowedStatComponent); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/stats/allowed_stat/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/stats/allowed_stat/translations.ts new file mode 100644 index 0000000000000..f9e9a26952528 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/stats/allowed_stat/translations.ts @@ -0,0 +1,25 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export const ALLOWED_TOOLTIP = ({ allowed, total }: { allowed: number; total: number }) => + i18n.translate( + 'xpack.elasticAssistant.dataAnonymizationEditor.stats.allowedStat.allowedTooltip', + { + values: { allowed, total }, + defaultMessage: + '{allowed} of {total} fields in this context are allowed to be included in the conversation', + } + ); + +export const ALLOWED = i18n.translate( + 'xpack.elasticAssistant.dataAnonymizationEditor.stats.allowedStat.allowedDescription', + { + defaultMessage: 'Allowed', + } +); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/stats/anonymized_stat/helpers.test.ts b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/stats/anonymized_stat/helpers.test.ts new file mode 100644 index 0000000000000..cbddce86cfd37 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/stats/anonymized_stat/helpers.test.ts @@ -0,0 +1,53 @@ +/* + * 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 { getColor, getTooltipContent } from './helpers'; + +describe('helpers', () => { + describe('getColor', () => { + it('returns `default` when isDataAnonymizable is true', () => { + const result = getColor(true); + + expect(result).toBe('default'); + }); + + it('returns `subdued` when isDataAnonymizable is false', () => { + const result = getColor(false); + + expect(result).toBe('subdued'); + }); + }); + + describe('getTooltipContent', () => { + it('informs the user that the context cannot be anonymized when isDataAnonymizable is false', () => { + const result = getTooltipContent({ anonymized: 0, isDataAnonymizable: false }); + + expect(result).toEqual('This context cannot be anonymized'); + }); + + it('returns the expected message when the data is anonymizable, but no data has been anonymized', () => { + const result = getTooltipContent({ anonymized: 0, isDataAnonymizable: true }); + expect(result).toEqual( + 'Select fields to be replaced with random values. Responses are automatically translated back to the original values.' + ); + }); + + it('returns the correct plural form of "field" when one field has been anonymized', () => { + const result = getTooltipContent({ anonymized: 1, isDataAnonymizable: true }); + expect(result).toEqual( + '1 field in this context will be replaced with random values. Responses are automatically translated back to the original values.' + ); + }); + + it('returns the correct plural form of "field" when more than one field has been anonymized', () => { + const result = getTooltipContent({ anonymized: 2, isDataAnonymizable: true }); + expect(result).toEqual( + '2 fields in this context will be replaced with random values. Responses are automatically translated back to the original values.' + ); + }); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/stats/anonymized_stat/helpers.ts b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/stats/anonymized_stat/helpers.ts new file mode 100644 index 0000000000000..bfffd9a1c5c13 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/stats/anonymized_stat/helpers.ts @@ -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 * as i18n from './translations'; + +export const getColor = (isDataAnonymizable: boolean): 'default' | 'subdued' => + isDataAnonymizable ? 'default' : 'subdued'; + +export const getTooltipContent = ({ + anonymized, + isDataAnonymizable, +}: { + anonymized: number; + isDataAnonymizable: boolean; +}): string => + !isDataAnonymizable || anonymized === 0 + ? i18n.NONE_OF_THE_DATA_WILL_BE_ANONYMIZED(isDataAnonymizable) + : i18n.FIELDS_WILL_BE_ANONYMIZED(anonymized); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/stats/anonymized_stat/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/stats/anonymized_stat/index.test.tsx new file mode 100644 index 0000000000000..a4b2b957d04c7 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/stats/anonymized_stat/index.test.tsx @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiToolTip } from '@elastic/eui'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { getTooltipContent } from './helpers'; +import * as i18n from './translations'; +import { AnonymizedStat } from '.'; +import { TestProviders } from '../../../mock/test_providers/test_providers'; + +const defaultProps = { + anonymized: 0, + isDataAnonymizable: false, + showIcon: false, +}; + +describe('AnonymizedStat', () => { + it('renders the expected content when the data is NOT anonymizable', () => { + render( + + + + ); + + expect(screen.getByTestId('anonymizedFieldsStat')).toHaveTextContent('0Anonymized'); + }); + + it('shows the anonymization icon when showIcon is true', () => { + render( + + + + ); + + expect(screen.getByTestId('anonymizationIcon')).toBeInTheDocument(); + }); + + it('does NOT show the anonymization icon when showIcon is false', () => { + render( + + + + ); + + expect(screen.queryByTestId('anonymizationIcon')).not.toBeInTheDocument(); + }); + + it('shows the correct tooltip content when anonymized is 0 and isDataAnonymizable is false', async () => { + render( + + + + ); + + userEvent.hover(screen.getByTestId('anonymizedFieldsStat')); + + await waitFor(() => { + expect(screen.getByText(i18n.NONE_OF_THE_DATA_WILL_BE_ANONYMIZED(false))).toBeInTheDocument(); + }); + }); + + it('shows correct tooltip content when anonymized is positive and isDataAnonymizable is true', async () => { + const anonymized = 3; + const isDataAnonymizable = true; + + render( + + + + ); + + userEvent.hover(screen.getByTestId('anonymizedFieldsStat')); + + await waitFor(() => { + expect(screen.getByText(i18n.FIELDS_WILL_BE_ANONYMIZED(anonymized))).toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/stats/anonymized_stat/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/stats/anonymized_stat/index.tsx new file mode 100644 index 0000000000000..574bc9aafc190 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/stats/anonymized_stat/index.tsx @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiStat, EuiText, EuiToolTip } from '@elastic/eui'; +import React, { useMemo } from 'react'; +// eslint-disable-next-line @kbn/eslint/module_migration +import styled from 'styled-components'; + +import { getColor, getTooltipContent } from './helpers'; +import { TITLE_SIZE } from '../constants'; +import * as i18n from './translations'; + +const ANONYMIZATION_ICON = 'eyeClosed'; + +const AnonymizationIconFlexItem = styled(EuiFlexItem)` + margin-right: ${({ theme }) => theme.eui.euiSizeS}; +`; + +interface Props { + anonymized: number; + isDataAnonymizable: boolean; + showIcon?: boolean; +} + +const AnonymizedStatComponent: React.FC = ({ + anonymized, + isDataAnonymizable, + showIcon = false, +}) => { + const color = useMemo(() => getColor(isDataAnonymizable), [isDataAnonymizable]); + + const tooltipContent = useMemo( + () => getTooltipContent({ anonymized, isDataAnonymizable }), + [anonymized, isDataAnonymizable] + ); + + const description = useMemo( + () => ( + + {showIcon && ( + + + + )} + + + + {i18n.ANONYMIZED_FIELDS} + + + + ), + [color, showIcon] + ); + + return ( + + + + ); +}; + +AnonymizedStatComponent.displayName = 'AnonymizedStatComponent'; + +export const AnonymizedStat = React.memo(AnonymizedStatComponent); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/stats/anonymized_stat/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/stats/anonymized_stat/translations.ts new file mode 100644 index 0000000000000..2decce2c7b794 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/stats/anonymized_stat/translations.ts @@ -0,0 +1,35 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export const ANONYMIZED_FIELDS = i18n.translate( + 'xpack.elasticAssistant.dataAnonymizationEditor.stats.anonymizedStat.anonymizeFieldsdDescription', + { + defaultMessage: 'Anonymized', + } +); + +export const FIELDS_WILL_BE_ANONYMIZED = (anonymized: number) => + i18n.translate( + 'xpack.elasticAssistant.dataAnonymizationEditor.stats.anonymizedStat.fieldsWillBeAnonymizedTooltip', + { + values: { anonymized }, + defaultMessage: + '{anonymized} {anonymized, plural, =1 {field} other {fields}} in this context will be replaced with random values. Responses are automatically translated back to the original values.', + } + ); + +export const NONE_OF_THE_DATA_WILL_BE_ANONYMIZED = (isDataAnonymizable: boolean) => + i18n.translate( + 'xpack.elasticAssistant.dataAnonymizationEditor.stats.anonymizedStat.noneOfTheDataWillBeAnonymizedTooltip', + { + values: { isDataAnonymizable }, + defaultMessage: + '{isDataAnonymizable, select, true {Select fields to be replaced with random values. Responses are automatically translated back to the original values.} other {This context cannot be anonymized}}', + } + ); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/stats/available_stat/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/stats/available_stat/index.test.tsx new file mode 100644 index 0000000000000..a969696622e4d --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/stats/available_stat/index.test.tsx @@ -0,0 +1,35 @@ +/* + * 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 { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { AvailableStat } from '.'; +import * as i18n from './translations'; + +describe('AvailableStat component', () => { + const total = 5; + + it('renders the expected stat content', () => { + render(); + + expect(screen.getByTestId('availableStat')).toHaveTextContent(`${total}Available`); + }); + + it('displays the tooltip with the correct content', async () => { + render(); + + userEvent.hover(screen.getByTestId('availableStat')); + + await waitFor(() => { + const tooltipContent = i18n.AVAILABLE_TOOLTIP(total); + + expect(screen.getByText(tooltipContent)).toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/stats/available_stat/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/stats/available_stat/index.tsx new file mode 100644 index 0000000000000..d675758951ca8 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/stats/available_stat/index.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiStat, EuiToolTip } from '@elastic/eui'; +import React, { useMemo } from 'react'; + +import { TITLE_SIZE } from '../constants'; +import * as i18n from './translations'; + +interface Props { + total: number; +} + +const AvailableStatComponent: React.FC = ({ total }) => { + const tooltipContent = useMemo(() => i18n.AVAILABLE_TOOLTIP(total), [total]); + + return ( + + + + ); +}; + +AvailableStatComponent.displayName = 'AvailableStatComponent'; + +export const AvailableStat = React.memo(AvailableStatComponent); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/stats/available_stat/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/stats/available_stat/translations.ts new file mode 100644 index 0000000000000..06e2343c98794 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/stats/available_stat/translations.ts @@ -0,0 +1,25 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export const AVAILABLE_TOOLTIP = (total: number) => + i18n.translate( + 'xpack.elasticAssistant.dataAnonymizationEditor.stats.availableStat.availableTooltip', + { + values: { total }, + defaultMessage: + '{total} fields in this context are available to be included in the conversation', + } + ); + +export const AVAILABLE = i18n.translate( + 'xpack.elasticAssistant.dataAnonymizationEditor.stats.availableStat.availableDescription', + { + defaultMessage: 'Available', + } +); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/stats/constants.ts b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/stats/constants.ts new file mode 100644 index 0000000000000..ba1e02e569bc2 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/stats/constants.ts @@ -0,0 +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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const TITLE_SIZE = 'xs'; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/stats/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/stats/index.test.tsx new file mode 100644 index 0000000000000..c1980a55e410d --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/stats/index.test.tsx @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; + +import { SelectedPromptContext } from '../../assistant/prompt_context/types'; +import { TestProviders } from '../../mock/test_providers/test_providers'; +import { Stats } from '.'; + +describe('Stats', () => { + const selectedPromptContext: SelectedPromptContext = { + allow: ['field1', 'field2'], + allowReplacement: ['field1'], + promptContextId: 'abcd', + rawData: { + field1: ['value1', 'value2'], + field2: ['value3, value4', 'value5'], + field3: ['value6'], + }, + }; + + it('renders the expected allowed stat content', () => { + render( + + + + ); + + expect(screen.getByTestId('allowedStat')).toHaveTextContent('2Allowed'); + }); + + it('renders the expected anonymized stat content', () => { + render( + + + + ); + + expect(screen.getByTestId('anonymizedFieldsStat')).toHaveTextContent('1Anonymized'); + }); + + it('renders the expected available stat content', () => { + render( + + + + ); + + expect(screen.getByTestId('availableStat')).toHaveTextContent('3Available'); + }); + + it('should not display the allowed stat when isDataAnonymizable is false', () => { + render( + + + + ); + + expect(screen.queryByTestId('allowedStat')).not.toBeInTheDocument(); + }); + + it('should not display the available stat when isDataAnonymizable is false', () => { + render( + + + + ); + + expect(screen.queryByTestId('availableStat')).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/stats/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/stats/index.tsx new file mode 100644 index 0000000000000..b0a27e271cdf7 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/stats/index.tsx @@ -0,0 +1,57 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import React, { useMemo } from 'react'; +// eslint-disable-next-line @kbn/eslint/module_migration +import styled from 'styled-components'; + +import { AllowedStat } from './allowed_stat'; +import { AnonymizedStat } from './anonymized_stat'; +import type { SelectedPromptContext } from '../../assistant/prompt_context/types'; +import { getStats } from '../get_stats'; +import { AvailableStat } from './available_stat'; + +const StatFlexItem = styled(EuiFlexItem)` + margin-right: ${({ theme }) => theme.eui.euiSizeL}; +`; + +interface Props { + isDataAnonymizable: boolean; + selectedPromptContext: SelectedPromptContext; +} + +const StatsComponent: React.FC = ({ isDataAnonymizable, selectedPromptContext }) => { + const { allowed, anonymized, total } = useMemo( + () => getStats(selectedPromptContext), + [selectedPromptContext] + ); + + return ( + + {isDataAnonymizable && ( + + + + )} + + + + + + {isDataAnonymizable && ( + + + + )} + + ); +}; + +StatsComponent.displayName = 'StatsComponent'; + +export const Stats = React.memo(StatsComponent); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/mock/get_anonymized_value/index.ts b/x-pack/packages/kbn-elastic-assistant/impl/mock/get_anonymized_value/index.ts new file mode 100644 index 0000000000000..1ec76a90d292b --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/mock/get_anonymized_value/index.ts @@ -0,0 +1,15 @@ +/* + * 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. + */ + +/** This mock returns the reverse of `value` */ +export const mockGetAnonymizedValue = ({ + currentReplacements, + rawValue, +}: { + currentReplacements: Record | undefined; + rawValue: string; +}): string => rawValue.split('').reverse().join(''); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/mock/test_providers/test_providers.tsx b/x-pack/packages/kbn-elastic-assistant/impl/mock/test_providers/test_providers.tsx index 9ceda348795ae..d3923b2ca8fd1 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/mock/test_providers/test_providers.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/mock/test_providers/test_providers.tsx @@ -34,9 +34,15 @@ export const TestProvidersComponent: React.FC = ({ children }) => { {children} diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/mock/test_providers/test_providers.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/mock/test_providers/test_providers.tsx index 7c9933942b270..b2a721329e1fa 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/mock/test_providers/test_providers.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/mock/test_providers/test_providers.tsx @@ -35,9 +35,15 @@ export const TestProvidersComponent: React.FC = ({ children }) => { {children} diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index ed9afb7fa9518..079ad8434f445 100644 --- a/x-pack/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/plugins/security_solution/common/experimental_features.ts @@ -82,7 +82,7 @@ export const allowedExperimentalValues = Object.freeze({ securityFlyoutEnabled: false, /** - * Enables the Elastic Security Assistant + * Enables the Elastic AI Assistant */ assistantEnabled: false, diff --git a/x-pack/plugins/security_solution/public/app/app.tsx b/x-pack/plugins/security_solution/public/app/app.tsx index a0e582b6b7ac2..3aac1b90bed37 100644 --- a/x-pack/plugins/security_solution/public/app/app.tsx +++ b/x-pack/plugins/security_solution/public/app/app.tsx @@ -34,9 +34,11 @@ import type { StartServices } from '../types'; import { PageRouter } from './routes'; import { UserPrivilegesProvider } from '../common/components/user_privileges/user_privileges_context'; import { ReactQueryClientProvider } from '../common/containers/query_client/query_client_provider'; +import { DEFAULT_ALLOW, DEFAULT_ALLOW_REPLACEMENT } from '../assistant/content/anonymization'; import { PROMPT_CONTEXTS } from '../assistant/content/prompt_contexts'; import { BASE_SECURITY_QUICK_PROMPTS } from '../assistant/content/quick_prompts'; import { BASE_SECURITY_SYSTEM_PROMPTS } from '../assistant/content/prompts/system'; +import { useAnonymizationStore } from '../assistant/use_anonymization_store'; interface StartAppComponent { children: React.ReactNode; @@ -64,6 +66,9 @@ const StartAppComponent: FC = ({ } = useKibana().services; const { conversations, setConversations } = useConversationStore(); + const { defaultAllow, defaultAllowReplacement, setDefaultAllow, setDefaultAllowReplacement } = + useAnonymizationStore(); + const getInitialConversation = useCallback(() => { return conversations; }, [conversations]); @@ -81,6 +86,10 @@ const StartAppComponent: FC = ({ = ({ http={http} nameSpace={nameSpace} setConversations={setConversations} + setDefaultAllow={setDefaultAllow} + setDefaultAllowReplacement={setDefaultAllowReplacement} title={ASSISTANT_TITLE} > diff --git a/x-pack/plugins/security_solution/public/app/translations.ts b/x-pack/plugins/security_solution/public/app/translations.ts index 50c3fae8f8ad2..9180894fbde84 100644 --- a/x-pack/plugins/security_solution/public/app/translations.ts +++ b/x-pack/plugins/security_solution/public/app/translations.ts @@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n'; export const ASSISTANT_TITLE = i18n.translate('xpack.securitySolution.assistant.title', { - defaultMessage: 'Elastic Security Assistant', + defaultMessage: 'Elastic AI Assistant', }); export const OVERVIEW = i18n.translate('xpack.securitySolution.navigation.overview', { diff --git a/x-pack/plugins/security_solution/public/assistant/comment_actions/index.tsx b/x-pack/plugins/security_solution/public/assistant/comment_actions/index.tsx index 6a782f6d58d84..befd0680c9c80 100644 --- a/x-pack/plugins/security_solution/public/assistant/comment_actions/index.tsx +++ b/x-pack/plugins/security_solution/public/assistant/comment_actions/index.tsx @@ -4,7 +4,8 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { EuiButtonIcon, EuiCopy, EuiToolTip } from '@elastic/eui'; + +import { EuiButtonIcon, EuiCopy, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; import { CommentType } from '@kbn/cases-plugin/common'; import type { Message } from '@kbn/elastic-assistant'; import React, { useCallback } from 'react'; @@ -61,45 +62,51 @@ const CommentActionsComponent: React.FC = ({ message }) => { { comment: message.content, type: CommentType.user, - owner: i18n.ELASTIC_SECURITY_ASSISTANT, + owner: i18n.ELASTIC_AI_ASSISTANT, }, ], }); }, [message.content, selectCaseModal]); return ( - <> - - - + + + + + + - - - + + + + + - - - {(copy) => ( - - )} - - - + + + + {(copy) => ( + + )} + + + + ); }; diff --git a/x-pack/plugins/security_solution/public/assistant/comment_actions/translations.ts b/x-pack/plugins/security_solution/public/assistant/comment_actions/translations.ts index 878fbab69d6af..1f85d7b2cb96f 100644 --- a/x-pack/plugins/security_solution/public/assistant/comment_actions/translations.ts +++ b/x-pack/plugins/security_solution/public/assistant/comment_actions/translations.ts @@ -35,10 +35,10 @@ export const ADD_TO_CASE_EXISTING_CASE = i18n.translate( } ); -export const ELASTIC_SECURITY_ASSISTANT = i18n.translate( - 'xpack.securitySolution.assistant.commentActions.elasticSecurityAssistantTitle', +export const ELASTIC_AI_ASSISTANT = i18n.translate( + 'xpack.securitySolution.assistant.commentActions.elasticAiAssistantTitle', { - defaultMessage: 'Elastic Security Assistant', + defaultMessage: 'Elastic AI Assistant', } ); diff --git a/x-pack/plugins/security_solution/public/assistant/content/anonymization/index.ts b/x-pack/plugins/security_solution/public/assistant/content/anonymization/index.ts new file mode 100644 index 0000000000000..84cd9ad09cacb --- /dev/null +++ b/x-pack/plugins/security_solution/public/assistant/content/anonymization/index.ts @@ -0,0 +1,58 @@ +/* + * 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. + */ + +/** By default, these fields are allowed to be sent to the assistant */ +export const DEFAULT_ALLOW = [ + '@timestamp', + 'cloud.availability_zone', + 'cloud.provider', + 'cloud.region', + 'destination.ip', + 'dns.question.name', + 'dns.question.type', + 'event.action', + 'event.category', + 'event.dataset', + 'event.module', + 'event.outcome', + 'event.type', + 'file.Ext.original.path', + 'file.hash.sha256', + 'file.name', + 'file.path', + 'host.name', + 'kibana.alert.rule.name', + 'network.protocol', + 'process.args', + 'process.exit_code', + 'process.hash.md5', + 'process.hash.sha1', + 'process.hash.sha256', + 'process.parent.name', + 'process.parent.pid', + 'process.name', + 'process.pid', + 'source.ip', + 'user.domain', + 'user.name', +]; + +/** By default, these fields will be anonymized */ +export const DEFAULT_ALLOW_REPLACEMENT = [ + 'cloud.availability_zone', + 'cloud.provider', + 'cloud.region', + 'destination.ip', + 'file.Ext.original.path', + 'file.name', + 'file.path', + 'host.ip', // not a default allow field, but anonymized by default + 'host.name', + 'source.ip', + 'user.domain', + 'user.name', +]; diff --git a/x-pack/plugins/security_solution/public/assistant/content/conversations/index.tsx b/x-pack/plugins/security_solution/public/assistant/content/conversations/index.tsx index 261906baa9150..b499092240377 100644 --- a/x-pack/plugins/security_solution/public/assistant/content/conversations/index.tsx +++ b/x-pack/plugins/security_solution/public/assistant/content/conversations/index.tsx @@ -6,7 +6,7 @@ */ import { - ELASTIC_SECURITY_ASSISTANT_TITLE, + ELASTIC_AI_ASSISTANT_TITLE, WELCOME_CONVERSATION_TITLE, } from '@kbn/elastic-assistant/impl/assistant/use_conversation/translations'; import type { Conversation } from '@kbn/elastic-assistant'; @@ -21,7 +21,7 @@ import { ALERT_SUMMARY_CONVERSATION_ID, EVENT_SUMMARY_CONVERSATION_ID, } from '../../../common/components/event_details/translations'; -import { ELASTIC_SECURITY_ASSISTANT } from '../../comment_actions/translations'; +import { ELASTIC_AI_ASSISTANT } from '../../comment_actions/translations'; import { TIMELINE_CONVERSATION_TITLE } from './translations'; export const BASE_SECURITY_CONVERSATIONS: Record = { @@ -59,10 +59,10 @@ export const BASE_SECURITY_CONVERSATIONS: Record = { id: WELCOME_CONVERSATION_TITLE, isDefault: true, theme: { - title: ELASTIC_SECURITY_ASSISTANT_TITLE, + title: ELASTIC_AI_ASSISTANT_TITLE, titleIcon: 'logoSecurity', assistant: { - name: ELASTIC_SECURITY_ASSISTANT, + name: ELASTIC_AI_ASSISTANT, icon: 'logoSecurity', }, system: { diff --git a/x-pack/plugins/security_solution/public/assistant/content/prompts/system/translations.ts b/x-pack/plugins/security_solution/public/assistant/content/prompts/system/translations.ts index a24e63f6c9be5..e060e88a2c8c7 100644 --- a/x-pack/plugins/security_solution/public/assistant/content/prompts/system/translations.ts +++ b/x-pack/plugins/security_solution/public/assistant/content/prompts/system/translations.ts @@ -11,7 +11,7 @@ export const YOU_ARE_A_HELPFUL_EXPERT_ASSISTANT = i18n.translate( 'xpack.securitySolution.assistant.content.prompts.system.youAreAHelpfulExpertAssistant', { defaultMessage: - 'You are a helpful, expert assistant who only answers questions about Elastic Security.', + 'You are a helpful, expert assistant who answers questions about Elastic Security.', } ); diff --git a/x-pack/plugins/security_solution/public/assistant/get_comments/index.tsx b/x-pack/plugins/security_solution/public/assistant/get_comments/index.tsx index fd0de680ee84c..d2c051285c8cd 100644 --- a/x-pack/plugins/security_solution/public/assistant/get_comments/index.tsx +++ b/x-pack/plugins/security_solution/public/assistant/get_comments/index.tsx @@ -16,23 +16,41 @@ import * as i18n from './translations'; export const getComments = ({ currentConversation, lastCommentRef, + showAnonymizedValues, }: { currentConversation: Conversation; lastCommentRef: React.MutableRefObject; + showAnonymizedValues: boolean; }): EuiCommentProps[] => currentConversation.messages.map((message, index) => { const isUser = message.role === 'user'; + const replacements = currentConversation.replacements; + const messageContentWithReplacements = + replacements != null + ? Object.keys(replacements).reduce( + (acc, replacement) => acc.replaceAll(replacement, replacements[replacement]), + message.content + ) + : message.content; + const transformedMessage = { + ...message, + content: messageContentWithReplacements, + }; return { - actions: , + actions: , children: index !== currentConversation.messages.length - 1 ? ( - {message.content} + + {showAnonymizedValues ? message.content : transformedMessage.content} + ) : ( - {message.content} + + {showAnonymizedValues ? message.content : transformedMessage.content} + ), diff --git a/x-pack/plugins/security_solution/public/assistant/helpers.tsx b/x-pack/plugins/security_solution/public/assistant/helpers.tsx index 896bb5b13cd65..829d36c0f9be7 100644 --- a/x-pack/plugins/security_solution/public/assistant/helpers.tsx +++ b/x-pack/plugins/security_solution/public/assistant/helpers.tsx @@ -34,6 +34,11 @@ export const getAllFields = (data: TimelineEventsDetailsItem[]): QueryField[] => .filter(({ field }) => !field.startsWith('signal.')) .map(({ field, values }) => ({ field, values: values?.join(',') ?? '' })); +export const getRawData = (data: TimelineEventsDetailsItem[]): Record => + data + .filter(({ field }) => !field.startsWith('signal.')) + .reduce((acc, { field, values }) => ({ ...acc, [field]: values ?? [] }), {}); + export const getFieldsAsCsv = (queryFields: QueryField[]): string => queryFields.map(({ field, values }) => `${field},${values}`).join('\n'); diff --git a/x-pack/plugins/security_solution/public/assistant/use_anonymization_store/index.tsx b/x-pack/plugins/security_solution/public/assistant/use_anonymization_store/index.tsx new file mode 100644 index 0000000000000..62ffa72713223 --- /dev/null +++ b/x-pack/plugins/security_solution/public/assistant/use_anonymization_store/index.tsx @@ -0,0 +1,41 @@ +/* + * 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 { useLocalStorage } from '../../common/components/local_storage'; +import { DEFAULT_ALLOW, DEFAULT_ALLOW_REPLACEMENT } from '../content/anonymization'; +import { LOCAL_STORAGE_KEY } from '../helpers'; + +export interface UseAnonymizationStore { + defaultAllow: string[]; + defaultAllowReplacement: string[]; + setDefaultAllow: React.Dispatch>; + setDefaultAllowReplacement: React.Dispatch>; +} + +const DEFAULT_ALLOW_KEY = `${LOCAL_STORAGE_KEY}.defaultAllow`; +const DEFAULT_ALLOW_REPLACEMENT_KEY = `${LOCAL_STORAGE_KEY}.defaultAllowReplacement`; + +export const useAnonymizationStore = (): UseAnonymizationStore => { + const [defaultAllow, setDefaultAllow] = useLocalStorage({ + defaultValue: DEFAULT_ALLOW, + key: DEFAULT_ALLOW_KEY, + isInvalidDefault: (valueFromStorage) => !Array.isArray(valueFromStorage), + }); + + const [defaultAllowReplacement, setDefaultAllowReplacement] = useLocalStorage({ + defaultValue: DEFAULT_ALLOW_REPLACEMENT, + key: DEFAULT_ALLOW_REPLACEMENT_KEY, + isInvalidDefault: (valueFromStorage) => !Array.isArray(valueFromStorage), + }); + + return { + defaultAllow, + defaultAllowReplacement, + setDefaultAllow, + setDefaultAllowReplacement, + }; +}; diff --git a/x-pack/plugins/security_solution/public/common/mock/storybook_providers.tsx b/x-pack/plugins/security_solution/public/common/mock/storybook_providers.tsx index c40fd2b6cebf3..d80a14872ccf6 100644 --- a/x-pack/plugins/security_solution/public/common/mock/storybook_providers.tsx +++ b/x-pack/plugins/security_solution/public/common/mock/storybook_providers.tsx @@ -69,9 +69,15 @@ export const StorybookProviders: React.FC = ({ children }) => { {children} diff --git a/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx b/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx index 89f5a6351411f..61f0a69266af5 100644 --- a/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx +++ b/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx @@ -77,9 +77,15 @@ export const TestProvidersComponent: React.FC = ({ @@ -124,9 +130,15 @@ const TestProvidersWithPrivilegesComponent: React.FC = ({ diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx index a1f6d45834f34..900a2aa9f870b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx @@ -12,7 +12,7 @@ import React, { useCallback, useMemo } from 'react'; import deepEqual from 'fast-deep-equal'; import type { EntityType } from '@kbn/timelines-plugin/common'; -import { getPromptContextFromEventDetailsItem } from '../../../../assistant/helpers'; +import { getRawData } from '../../../../assistant/helpers'; import type { BrowserFields } from '../../../../common/containers/source'; import { ExpandableEvent, ExpandableEventTitle } from './expandable_event'; import { useTimelineEventsDetails } from '../../../containers/details'; @@ -102,10 +102,7 @@ const EventDetailsPanelComponent: React.FC = ({ const view = useMemo(() => (isFlyoutView ? SUMMARY_VIEW : TIMELINE_VIEW), [isFlyoutView]); - const getPromptContext = useCallback( - async () => getPromptContextFromEventDetailsItem(detailsData ?? []), - [detailsData] - ); + const getPromptContext = useCallback(async () => getRawData(detailsData ?? []), [detailsData]); const { promptContextId } = useAssistant( isAlert ? 'alert' : 'event',