From c5aa73991463dee50b036bb51645e03cc283bb8c Mon Sep 17 00:00:00 2001 From: Yan Savitski Date: Wed, 2 Oct 2024 10:45:20 +0200 Subject: [PATCH] [Search][Onboarding] api-key plugin (#191926) ## Summary Kibana plugin that helps manage the session for the api-key that provides two exports: - React custom hook to read the api-key stored in session. This hook should return the api-key if it exists, otherwise null. - Component to present either the api key in storage or action to create api key and store into sessionStorage after callback. Screenshot 2024-09-27 at 20 52 52 Screenshot 2024-09-27 at 20 52 39 Screenshot 2024-09-27 at 20 52 28 --- .github/CODEOWNERS | 3 + .i18nrc.json | 1 + package.json | 3 + .../kbn-search-api-keys-components/README.md | 3 + .../kbn-search-api-keys-components/index.ts | 13 ++ .../jest.config.js | 20 ++ .../kibana.jsonc | 5 + .../package.json | 6 + .../src/components/api_key_flyout_wrapper.tsx | 25 +++ .../src/components/api_key_form.tsx | 101 ++++++++++ .../src/constants.ts | 17 ++ .../src/hooks/use_search_api_key.ts | 19 ++ .../src/providers/search_api_key_provider.tsx | 182 ++++++++++++++++++ .../src/types.ts | 13 ++ .../tsconfig.json | 21 ++ packages/kbn-search-api-keys-server/README.md | 3 + packages/kbn-search-api-keys-server/index.ts | 10 + .../kbn-search-api-keys-server/jest.config.js | 17 ++ .../kbn-search-api-keys-server/kibana.jsonc | 5 + .../kbn-search-api-keys-server/package.json | 6 + .../src/lib/create_key.ts | 31 +++ .../src/lib/get_key_by_id.ts | 30 +++ .../src/lib/privileges.ts | 50 +++++ .../src/routes/routes.ts | 108 +++++++++++ .../kbn-search-api-keys-server/tsconfig.json | 20 ++ packages/kbn-search-api-keys-server/types.ts | 27 +++ tsconfig.base.json | 12 +- x-pack/.i18nrc.json | 1 + x-pack/packages/search/shared_ui/README.md | 3 + x-pack/packages/search/shared_ui/index.ts | 8 + .../packages/search/shared_ui/jest.config.js | 15 ++ x-pack/packages/search/shared_ui/kibana.jsonc | 5 + x-pack/packages/search/shared_ui/package.json | 6 + .../src/form_info_field/form_info_field.tsx | 88 +++++++++ .../packages/search/shared_ui/tsconfig.json | 22 +++ .../src/components/api_key_flyout.tsx | 8 + x-pack/plugins/search_indices/kibana.jsonc | 2 +- .../search_indices/public/application.tsx | 5 +- .../connection_details/connection_details.tsx | 63 +----- .../add_documents_code_example.tsx | 13 +- .../components/indices/details_page.tsx | 9 +- .../components/start/create_index_code.tsx | 36 +++- .../search_indices/server/routes/index.ts | 2 + x-pack/plugins/search_indices/tsconfig.json | 3 + .../search_playground/server/routes.ts | 38 ---- .../functional/page_objects/index.ts | 2 + .../functional/page_objects/svl_api_keys.ts | 108 +++++++++++ .../svl_search_elasticsearch_start_page.ts | 6 + .../svl_search_index_detail_page.ts | 7 + .../test_suites/search/elasticsearch_start.ts | 113 ++++++++--- .../test_suites/search/search_index_detail.ts | 8 + yarn.lock | 12 ++ 52 files changed, 1208 insertions(+), 126 deletions(-) create mode 100644 packages/kbn-search-api-keys-components/README.md create mode 100644 packages/kbn-search-api-keys-components/index.ts create mode 100644 packages/kbn-search-api-keys-components/jest.config.js create mode 100644 packages/kbn-search-api-keys-components/kibana.jsonc create mode 100644 packages/kbn-search-api-keys-components/package.json create mode 100644 packages/kbn-search-api-keys-components/src/components/api_key_flyout_wrapper.tsx create mode 100644 packages/kbn-search-api-keys-components/src/components/api_key_form.tsx create mode 100644 packages/kbn-search-api-keys-components/src/constants.ts create mode 100644 packages/kbn-search-api-keys-components/src/hooks/use_search_api_key.ts create mode 100644 packages/kbn-search-api-keys-components/src/providers/search_api_key_provider.tsx create mode 100644 packages/kbn-search-api-keys-components/src/types.ts create mode 100644 packages/kbn-search-api-keys-components/tsconfig.json create mode 100644 packages/kbn-search-api-keys-server/README.md create mode 100644 packages/kbn-search-api-keys-server/index.ts create mode 100644 packages/kbn-search-api-keys-server/jest.config.js create mode 100644 packages/kbn-search-api-keys-server/kibana.jsonc create mode 100644 packages/kbn-search-api-keys-server/package.json create mode 100644 packages/kbn-search-api-keys-server/src/lib/create_key.ts create mode 100644 packages/kbn-search-api-keys-server/src/lib/get_key_by_id.ts create mode 100644 packages/kbn-search-api-keys-server/src/lib/privileges.ts create mode 100644 packages/kbn-search-api-keys-server/src/routes/routes.ts create mode 100644 packages/kbn-search-api-keys-server/tsconfig.json create mode 100644 packages/kbn-search-api-keys-server/types.ts create mode 100644 x-pack/packages/search/shared_ui/README.md create mode 100644 x-pack/packages/search/shared_ui/index.ts create mode 100644 x-pack/packages/search/shared_ui/jest.config.js create mode 100644 x-pack/packages/search/shared_ui/kibana.jsonc create mode 100644 x-pack/packages/search/shared_ui/package.json create mode 100644 x-pack/packages/search/shared_ui/src/form_info_field/form_info_field.tsx create mode 100644 x-pack/packages/search/shared_ui/tsconfig.json create mode 100644 x-pack/test_serverless/functional/page_objects/svl_api_keys.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b3af3edce4585..4d42183c92991 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -753,6 +753,8 @@ src/plugins/screenshot_mode @elastic/appex-sharedux x-pack/examples/screenshotting_example @elastic/appex-sharedux x-pack/plugins/screenshotting @elastic/kibana-reporting-services packages/kbn-screenshotting-server @elastic/appex-sharedux +packages/kbn-search-api-keys-components @elastic/search-kibana +packages/kbn-search-api-keys-server @elastic/search-kibana packages/kbn-search-api-panels @elastic/search-kibana x-pack/plugins/search_assistant @elastic/search-kibana packages/kbn-search-connectors @elastic/search-kibana @@ -766,6 +768,7 @@ x-pack/plugins/search_inference_endpoints @elastic/search-kibana x-pack/plugins/search_notebooks @elastic/search-kibana x-pack/plugins/search_playground @elastic/search-kibana packages/kbn-search-response-warnings @elastic/kibana-data-discovery +x-pack/packages/search/shared_ui @elastic/search-kibana packages/kbn-search-types @elastic/kibana-data-discovery x-pack/plugins/searchprofiler @elastic/kibana-management x-pack/test/security_api_integration/packages/helpers @elastic/kibana-security diff --git a/.i18nrc.json b/.i18nrc.json index e9500448b9a09..036be597ac969 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -109,6 +109,7 @@ "server": "src/legacy/server", "share": ["src/plugins/share", "packages/kbn-reporting-share"], "sharedUXPackages": "packages/shared-ux", + "searchApiKeysComponents": "packages/kbn-search-api-keys-components", "searchApiPanels": "packages/kbn-search-api-panels/", "searchErrors": "packages/kbn-search-errors", "searchIndexDocuments": "packages/kbn-search-index-documents", diff --git a/package.json b/package.json index 525bedcab5ef9..b765f5867f9c4 100644 --- a/package.json +++ b/package.json @@ -771,6 +771,8 @@ "@kbn/screenshotting-example-plugin": "link:x-pack/examples/screenshotting_example", "@kbn/screenshotting-plugin": "link:x-pack/plugins/screenshotting", "@kbn/screenshotting-server": "link:packages/kbn-screenshotting-server", + "@kbn/search-api-keys-components": "link:packages/kbn-search-api-keys-components", + "@kbn/search-api-keys-server": "link:packages/kbn-search-api-keys-server", "@kbn/search-api-panels": "link:packages/kbn-search-api-panels", "@kbn/search-assistant": "link:x-pack/plugins/search_assistant", "@kbn/search-connectors": "link:packages/kbn-search-connectors", @@ -784,6 +786,7 @@ "@kbn/search-notebooks": "link:x-pack/plugins/search_notebooks", "@kbn/search-playground": "link:x-pack/plugins/search_playground", "@kbn/search-response-warnings": "link:packages/kbn-search-response-warnings", + "@kbn/search-shared-ui": "link:x-pack/packages/search/shared_ui", "@kbn/search-types": "link:packages/kbn-search-types", "@kbn/searchprofiler-plugin": "link:x-pack/plugins/searchprofiler", "@kbn/security-api-key-management": "link:x-pack/packages/security/api_key_management", diff --git a/packages/kbn-search-api-keys-components/README.md b/packages/kbn-search-api-keys-components/README.md new file mode 100644 index 0000000000000..06815bf556f00 --- /dev/null +++ b/packages/kbn-search-api-keys-components/README.md @@ -0,0 +1,3 @@ +# Search API Key Components + +The Search API Keys components package is a shared components and utilities to simplify managing the API Keys experience for elasticsearch users across stack and serverless search solutions. \ No newline at end of file diff --git a/packages/kbn-search-api-keys-components/index.ts b/packages/kbn-search-api-keys-components/index.ts new file mode 100644 index 0000000000000..d188bdb956913 --- /dev/null +++ b/packages/kbn-search-api-keys-components/index.ts @@ -0,0 +1,13 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export * from './src/components/api_key_flyout_wrapper'; +export * from './src/components/api_key_form'; +export * from './src/hooks/use_search_api_key'; +export * from './src/providers/search_api_key_provider'; diff --git a/packages/kbn-search-api-keys-components/jest.config.js b/packages/kbn-search-api-keys-components/jest.config.js new file mode 100644 index 0000000000000..d1868067bb31c --- /dev/null +++ b/packages/kbn-search-api-keys-components/jest.config.js @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../..', + roots: ['/packages/kbn-search-api-keys-components'], + coverageDirectory: + '/target/kibana-coverage/jest/packages/kbn-search-api-keys-components', + coverageReporters: ['text', 'html'], + collectCoverageFrom: [ + '/packages/kbn-search-api-keys-components/public/{components,hooks}/**/*.{ts,tsx}', + ], +}; diff --git a/packages/kbn-search-api-keys-components/kibana.jsonc b/packages/kbn-search-api-keys-components/kibana.jsonc new file mode 100644 index 0000000000000..bedd4c213760f --- /dev/null +++ b/packages/kbn-search-api-keys-components/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-browser", + "id": "@kbn/search-api-keys-components", + "owner": "@elastic/search-kibana" +} \ No newline at end of file diff --git a/packages/kbn-search-api-keys-components/package.json b/packages/kbn-search-api-keys-components/package.json new file mode 100644 index 0000000000000..1405cb6c27bed --- /dev/null +++ b/packages/kbn-search-api-keys-components/package.json @@ -0,0 +1,6 @@ +{ + "name": "@kbn/search-api-keys-components", + "private": true, + "version": "1.0.0", + "license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0" +} \ No newline at end of file diff --git a/packages/kbn-search-api-keys-components/src/components/api_key_flyout_wrapper.tsx b/packages/kbn-search-api-keys-components/src/components/api_key_flyout_wrapper.tsx new file mode 100644 index 0000000000000..35db216df1ef3 --- /dev/null +++ b/packages/kbn-search-api-keys-components/src/components/api_key_flyout_wrapper.tsx @@ -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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { ApiKeyFlyout, ApiKeyFlyoutProps } from '@kbn/security-api-key-management'; +import type { SecurityCreateApiKeyResponse } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; + +const API_KEY_NAME = 'Unrestricted API Key'; + +type ApiKeyFlyoutWrapperProps = Pick & { + onSuccess?: (createApiKeyResponse: SecurityCreateApiKeyResponse) => void; +}; + +export const ApiKeyFlyoutWrapper: React.FC = ({ + onCancel, + onSuccess, +}) => { + return ; +}; diff --git a/packages/kbn-search-api-keys-components/src/components/api_key_form.tsx b/packages/kbn-search-api-keys-components/src/components/api_key_form.tsx new file mode 100644 index 0000000000000..ccd169c413bb1 --- /dev/null +++ b/packages/kbn-search-api-keys-components/src/components/api_key_form.tsx @@ -0,0 +1,101 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React, { useState } from 'react'; +import { + EuiBadge, + EuiButton, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiTitle, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { i18n } from '@kbn/i18n'; +import { FormInfoField } from '@kbn/search-shared-ui'; +import { ApiKeyFlyoutWrapper } from './api_key_flyout_wrapper'; +import { useSearchApiKey } from '../hooks/use_search_api_key'; +import { Status } from '../constants'; + +interface ApiKeyFormProps { + hasTitle?: boolean; +} + +export const ApiKeyForm: React.FC = ({ hasTitle = true }) => { + const [showFlyout, setShowFlyout] = useState(false); + const { apiKey, status, updateApiKey, toggleApiKeyVisibility, displayedApiKey, apiKeyIsVisible } = + useSearchApiKey(); + + const titleLocale = i18n.translate('searchApiKeysComponents.apiKeyForm.title', { + defaultMessage: 'API Key', + }); + + if (apiKey && displayedApiKey) { + return ( + , + ]} + /> + ); + } + + return ( + + {hasTitle && ( + + +
{titleLocale}
+
+
+ )} + {status === Status.showUserPrivilegesError && ( + + + {i18n.translate('searchApiKeysComponents.apiKeyForm.noUserPrivileges', { + defaultMessage: "You don't have access to manage API keys", + })} + + + )} + {status === Status.showCreateButton && ( + + setShowFlyout(true)} + data-test-subj="createAPIKeyButton" + > + + + {showFlyout && ( + setShowFlyout(false)} onSuccess={updateApiKey} /> + )} + + )} +
+ ); +}; diff --git a/packages/kbn-search-api-keys-components/src/constants.ts b/packages/kbn-search-api-keys-components/src/constants.ts new file mode 100644 index 0000000000000..b822ccb042a70 --- /dev/null +++ b/packages/kbn-search-api-keys-components/src/constants.ts @@ -0,0 +1,17 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export enum Status { + uninitialized = 'uninitialized', + loading = 'loading', + showCreateButton = 'showCreateButton', + showHiddenKey = 'showHiddenKey', + showPreviewKey = 'showPreviewKey', + showUserPrivilegesError = 'showUserPrivilegesError', +} diff --git a/packages/kbn-search-api-keys-components/src/hooks/use_search_api_key.ts b/packages/kbn-search-api-keys-components/src/hooks/use_search_api_key.ts new file mode 100644 index 0000000000000..9d0bd4826f9de --- /dev/null +++ b/packages/kbn-search-api-keys-components/src/hooks/use_search_api_key.ts @@ -0,0 +1,19 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { useContext, useEffect } from 'react'; +import { ApiKeyContext } from '../providers/search_api_key_provider'; + +export const useSearchApiKey = () => { + const { initialiseKey, ...context } = useContext(ApiKeyContext); + useEffect(() => { + initialiseKey(); + }, [initialiseKey]); + return context; +}; diff --git a/packages/kbn-search-api-keys-components/src/providers/search_api_key_provider.tsx b/packages/kbn-search-api-keys-components/src/providers/search_api_key_provider.tsx new file mode 100644 index 0000000000000..f3a81e72d3b2e --- /dev/null +++ b/packages/kbn-search-api-keys-components/src/providers/search_api_key_provider.tsx @@ -0,0 +1,182 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React, { useCallback, useReducer, createContext, useEffect } from 'react'; +import { useMutation } from '@tanstack/react-query'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import type { APIKeyCreationResponse } from '@kbn/search-api-keys-server/types'; +import { APIRoutes } from '../types'; +import { Status } from '../constants'; + +const API_KEY_STORAGE_KEY = 'searchApiKey'; +const API_KEY_MASK = '•'.repeat(60); + +interface ApiKeyState { + status: Status; + apiKey: string | null; +} + +interface APIKeyContext { + displayedApiKey: string | null; + apiKey: string | null; + toggleApiKeyVisibility: () => void; + updateApiKey: ({ id, encoded }: { id: string; encoded: string }) => void; + status: Status; + apiKeyIsVisible: boolean; + initialiseKey: () => void; +} + +type Action = + | { type: 'SET_API_KEY'; apiKey: string; status: Status } + | { type: 'SET_STATUS'; status: Status } + | { type: 'CLEAR_API_KEY' } + | { type: 'TOGGLE_API_KEY_VISIBILITY' }; + +const initialState: ApiKeyState = { + apiKey: null, + status: Status.uninitialized, +}; + +const reducer = (state: ApiKeyState, action: Action): ApiKeyState => { + switch (action.type) { + case 'SET_API_KEY': + return { ...state, apiKey: action.apiKey, status: action.status }; + case 'SET_STATUS': + return { ...state, status: action.status }; + case 'TOGGLE_API_KEY_VISIBILITY': + return { + ...state, + status: + state.status === Status.showHiddenKey ? Status.showPreviewKey : Status.showHiddenKey, + }; + case 'CLEAR_API_KEY': + return { ...state, apiKey: null, status: Status.showCreateButton }; + default: + return state; + } +}; + +export const ApiKeyContext = createContext({ + displayedApiKey: null, + apiKey: null, + toggleApiKeyVisibility: () => {}, + updateApiKey: () => {}, + status: Status.uninitialized, + apiKeyIsVisible: false, + initialiseKey: () => {}, +}); + +export const SearchApiKeyProvider: React.FC = ({ children }) => { + const { http } = useKibana().services; + const [state, dispatch] = useReducer(reducer, initialState); + + const updateApiKey = useCallback(({ id, encoded }: { id: string; encoded: string }) => { + sessionStorage.setItem(API_KEY_STORAGE_KEY, JSON.stringify({ id, encoded })); + dispatch({ type: 'SET_API_KEY', apiKey: encoded, status: Status.showHiddenKey }); + }, []); + const handleShowKeyVisibility = useCallback(() => { + dispatch({ type: 'TOGGLE_API_KEY_VISIBILITY' }); + }, []); + const initialiseKey = useCallback(() => { + dispatch({ type: 'SET_STATUS', status: Status.loading }); + }, []); + const { mutateAsync: validateApiKey } = useMutation(async (id: string) => { + try { + if (!http?.post) { + throw new Error('HTTP service is unavailable'); + } + + const response = await http.post<{ isValid: boolean }>(APIRoutes.API_KEY_VALIDITY, { + body: JSON.stringify({ id }), + }); + + return response.isValid; + } catch (err) { + return false; + } + }); + const { mutateAsync: createApiKey } = useMutation({ + mutationFn: async () => { + try { + if (!http?.post) { + throw new Error('HTTP service is unavailable'); + } + + return await http.post(APIRoutes.API_KEYS); + } catch (err) { + if (err.response?.status === 400) { + dispatch({ type: 'SET_STATUS', status: Status.showCreateButton }); + } else if (err.response?.status === 403) { + dispatch({ type: 'SET_STATUS', status: Status.showUserPrivilegesError }); + } else { + throw err; + } + } + }, + onSuccess: (receivedApiKey) => { + if (receivedApiKey) { + sessionStorage.setItem( + API_KEY_STORAGE_KEY, + JSON.stringify({ id: receivedApiKey.id, encoded: receivedApiKey.encoded }) + ); + dispatch({ + type: 'SET_API_KEY', + apiKey: receivedApiKey.encoded, + status: Status.showHiddenKey, + }); + } + }, + }); + + useEffect(() => { + const initialiseApiKey = async () => { + try { + if (state.status === Status.loading) { + const storedKey = sessionStorage.getItem(API_KEY_STORAGE_KEY); + + if (storedKey) { + const { id, encoded } = JSON.parse(storedKey); + + if (await validateApiKey(id)) { + dispatch({ + type: 'SET_API_KEY', + apiKey: encoded, + status: Status.showHiddenKey, + }); + } else { + sessionStorage.removeItem(API_KEY_STORAGE_KEY); + dispatch({ + type: 'CLEAR_API_KEY', + }); + await createApiKey(); + } + } else { + await createApiKey(); + } + } + } catch (e) { + dispatch({ type: 'CLEAR_API_KEY' }); + } + }; + + initialiseApiKey(); + }, [state.status, createApiKey, validateApiKey]); + + const value: APIKeyContext = { + displayedApiKey: state.status === Status.showHiddenKey ? API_KEY_MASK : state.apiKey, + apiKey: state.apiKey, + toggleApiKeyVisibility: handleShowKeyVisibility, + updateApiKey, + status: state.status, + apiKeyIsVisible: state.status === Status.showPreviewKey, + initialiseKey, + }; + + return {children}; +}; diff --git a/packages/kbn-search-api-keys-components/src/types.ts b/packages/kbn-search-api-keys-components/src/types.ts new file mode 100644 index 0000000000000..146b5fdd2faca --- /dev/null +++ b/packages/kbn-search-api-keys-components/src/types.ts @@ -0,0 +1,13 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export enum APIRoutes { + API_KEYS = '/internal/search_api_keys', + API_KEY_VALIDITY = '/internal/search_api_keys/validity', +} diff --git a/packages/kbn-search-api-keys-components/tsconfig.json b/packages/kbn-search-api-keys-components/tsconfig.json new file mode 100644 index 0000000000000..e81fb5bc36996 --- /dev/null +++ b/packages/kbn-search-api-keys-components/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + }, + "include": [ + "src/**/*", + "index.ts", + ], + "kbn_references": [ + "@kbn/i18n", + "@kbn/i18n-react", + "@kbn/kibana-react-plugin", + "@kbn/security-api-key-management", + "@kbn/search-shared-ui", + "@kbn/search-api-keys-server" + ], + "exclude": [ + "target/**/*", + ] +} diff --git a/packages/kbn-search-api-keys-server/README.md b/packages/kbn-search-api-keys-server/README.md new file mode 100644 index 0000000000000..82a42ad81b5b6 --- /dev/null +++ b/packages/kbn-search-api-keys-server/README.md @@ -0,0 +1,3 @@ +# Search API Keys + +The Search API Keys package is a shared components and utilities to simplify managing the API Keys experience for elasticsearch users across stack and serverless search solutions. \ No newline at end of file diff --git a/packages/kbn-search-api-keys-server/index.ts b/packages/kbn-search-api-keys-server/index.ts new file mode 100644 index 0000000000000..e9287e9debdd0 --- /dev/null +++ b/packages/kbn-search-api-keys-server/index.ts @@ -0,0 +1,10 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export * from './src/routes/routes'; diff --git a/packages/kbn-search-api-keys-server/jest.config.js b/packages/kbn-search-api-keys-server/jest.config.js new file mode 100644 index 0000000000000..af09324461569 --- /dev/null +++ b/packages/kbn-search-api-keys-server/jest.config.js @@ -0,0 +1,17 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +module.exports = { + preset: '@kbn/test/jest_node', + rootDir: '../..', + roots: ['/packages/kbn-search-api-keys-server'], + coverageDirectory: '/target/kibana-coverage/jest/packages/kbn-search-api-keys-server', + coverageReporters: ['text', 'html'], + collectCoverageFrom: ['/packages/kbn-search-api-keys-server/**/*.{ts,tsx}'], +}; diff --git a/packages/kbn-search-api-keys-server/kibana.jsonc b/packages/kbn-search-api-keys-server/kibana.jsonc new file mode 100644 index 0000000000000..52c6cd9653968 --- /dev/null +++ b/packages/kbn-search-api-keys-server/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-server", + "id": "@kbn/search-api-keys-server", + "owner": "@elastic/search-kibana" +} \ No newline at end of file diff --git a/packages/kbn-search-api-keys-server/package.json b/packages/kbn-search-api-keys-server/package.json new file mode 100644 index 0000000000000..6c891e7325a2e --- /dev/null +++ b/packages/kbn-search-api-keys-server/package.json @@ -0,0 +1,6 @@ +{ + "name": "@kbn/search-api-keys-server", + "private": true, + "version": "1.0.0", + "license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0" +} \ No newline at end of file diff --git a/packages/kbn-search-api-keys-server/src/lib/create_key.ts b/packages/kbn-search-api-keys-server/src/lib/create_key.ts new file mode 100644 index 0000000000000..7bebe713810c8 --- /dev/null +++ b/packages/kbn-search-api-keys-server/src/lib/create_key.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; +import type { Logger } from '@kbn/logging'; +import type { APIKeyCreationResponse } from '../../types'; + +export async function createAPIKey( + name: string, + client: ElasticsearchClient, + logger: Logger +): Promise { + try { + const apiKey = await client.security.createApiKey({ + name, + role_descriptors: {}, + }); + + return apiKey; + } catch (e) { + logger.error(`Search API Keys: Error during creating API Key`); + logger.error(e); + throw e; + } +} diff --git a/packages/kbn-search-api-keys-server/src/lib/get_key_by_id.ts b/packages/kbn-search-api-keys-server/src/lib/get_key_by_id.ts new file mode 100644 index 0000000000000..94ae64f56c0da --- /dev/null +++ b/packages/kbn-search-api-keys-server/src/lib/get_key_by_id.ts @@ -0,0 +1,30 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; +import type { Logger } from '@kbn/logging'; +import type { GetApiKeyResponse } from '../../types'; + +export async function getAPIKeyById( + id: string, + client: ElasticsearchClient, + logger: Logger +): Promise { + try { + const apiKey = await client.security.getApiKey({ + id, + }); + + return apiKey.api_keys?.[0]; + } catch (e) { + logger.error(`Search API Keys: Error on getting API Key`); + logger.error(e); + throw e; + } +} diff --git a/packages/kbn-search-api-keys-server/src/lib/privileges.ts b/packages/kbn-search-api-keys-server/src/lib/privileges.ts new file mode 100644 index 0000000000000..fc5ad1f896746 --- /dev/null +++ b/packages/kbn-search-api-keys-server/src/lib/privileges.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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; +import type { Logger } from '@kbn/logging'; + +export async function fetchUserStartPrivileges( + client: ElasticsearchClient, + logger: Logger +): Promise { + try { + // relying on manage cluster privilege to check if user can create API keys + // and can also have permissions for index monitoring + const securityCheck = await client.security.hasPrivileges({ + cluster: ['manage'], + }); + + return securityCheck?.cluster?.manage ?? false; + } catch (e) { + logger.error(`Error checking user privileges for search API Keys`); + logger.error(e); + return false; + } +} + +export async function fetchClusterHasApiKeys( + client: ElasticsearchClient, + logger: Logger +): Promise { + try { + const clusterApiKeys = await client.security.queryApiKeys({ + query: { + term: { + invalidated: false, + }, + }, + }); + return clusterApiKeys.api_keys.length > 0; + } catch (e) { + logger.error(`Error checking cluster for existing valid API keys`); + logger.error(e); + return true; + } +} diff --git a/packages/kbn-search-api-keys-server/src/routes/routes.ts b/packages/kbn-search-api-keys-server/src/routes/routes.ts new file mode 100644 index 0000000000000..77a08644f34a5 --- /dev/null +++ b/packages/kbn-search-api-keys-server/src/routes/routes.ts @@ -0,0 +1,108 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { IRouter } from '@kbn/core/server'; +import type { Logger } from '@kbn/logging'; + +import { schema } from '@kbn/config-schema'; +import { APIRoutes } from '../../types'; +import { getAPIKeyById } from '../lib/get_key_by_id'; +import { createAPIKey } from '../lib/create_key'; +import { fetchClusterHasApiKeys, fetchUserStartPrivileges } from '../lib/privileges'; + +const API_KEY_NAME = 'Unrestricted API Key'; + +export function registerSearchApiKeysRoutes(router: IRouter, logger: Logger) { + router.post( + { + path: APIRoutes.API_KEY_VALIDITY, + validate: { + body: schema.object({ + id: schema.string(), + }), + }, + options: { + access: 'internal', + }, + }, + async (context, request, response) => { + try { + const core = await context.core; + const client = core.elasticsearch.client.asCurrentUser; + const apiKey = await getAPIKeyById(request.body.id, client, logger); + + if (!apiKey) { + return response.customError({ + body: { message: 'API key is not found.' }, + statusCode: 404, + }); + } + + return response.ok({ + body: { isValid: !apiKey.invalidated }, + headers: { 'content-type': 'application/json' }, + }); + } catch (e) { + logger.error(`Error fetching API Key`); + logger.error(e); + return response.customError({ + body: { message: e.message }, + statusCode: 500, + }); + } + } + ); + + router.post( + { + path: APIRoutes.API_KEYS, + validate: {}, + options: { + access: 'internal', + }, + }, + async (context, _request, response) => { + try { + const core = await context.core; + const client = core.elasticsearch.client.asCurrentUser; + const clusterHasApiKeys = await fetchClusterHasApiKeys(client, logger); + + if (clusterHasApiKeys) { + return response.customError({ + body: { message: 'Project already has API keys' }, + statusCode: 400, + }); + } + + const canCreateApiKeys = await fetchUserStartPrivileges(client, logger); + + if (!canCreateApiKeys) { + return response.customError({ + body: { message: 'User does not have required privileges' }, + statusCode: 403, + }); + } + + const apiKey = await createAPIKey(API_KEY_NAME, client, logger); + + return response.ok({ + body: apiKey, + headers: { 'content-type': 'application/json' }, + }); + } catch (e) { + logger.error(`Error creating API Key`); + logger.error(e); + return response.customError({ + body: { message: e.message }, + statusCode: 500, + }); + } + } + ); +} diff --git a/packages/kbn-search-api-keys-server/tsconfig.json b/packages/kbn-search-api-keys-server/tsconfig.json new file mode 100644 index 0000000000000..9a82ed8904725 --- /dev/null +++ b/packages/kbn-search-api-keys-server/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + }, + "include": [ + "src/**/*", + "types.ts", + "index.ts" + ], + "kbn_references": [ + "@kbn/core-elasticsearch-server", + "@kbn/logging", + "@kbn/core", + "@kbn/config-schema", + ], + "exclude": [ + "target/**/*", + ] +} diff --git a/packages/kbn-search-api-keys-server/types.ts b/packages/kbn-search-api-keys-server/types.ts new file mode 100644 index 0000000000000..6f7ff29493ffd --- /dev/null +++ b/packages/kbn-search-api-keys-server/types.ts @@ -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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export enum APIRoutes { + API_KEYS = '/internal/search_api_keys', + API_KEY_VALIDITY = '/internal/search_api_keys/validity', +} + +export interface APIKey { + id: string; + name: string; + expiration?: number; + invalidated?: boolean; +} + +export interface APIKeyCreationResponse extends Pick { + api_key: string; + encoded: string; +} + +export type GetApiKeyResponse = APIKey; diff --git a/tsconfig.base.json b/tsconfig.base.json index c0a3aed9ff3ac..936c60930d262 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1500,6 +1500,10 @@ "@kbn/screenshotting-plugin/*": ["x-pack/plugins/screenshotting/*"], "@kbn/screenshotting-server": ["packages/kbn-screenshotting-server"], "@kbn/screenshotting-server/*": ["packages/kbn-screenshotting-server/*"], + "@kbn/search-api-keys-components": ["packages/kbn-search-api-keys-components"], + "@kbn/search-api-keys-components/*": ["packages/kbn-search-api-keys-components/*"], + "@kbn/search-api-keys-server": ["packages/kbn-search-api-keys-server"], + "@kbn/search-api-keys-server/*": ["packages/kbn-search-api-keys-server/*"], "@kbn/search-api-panels": ["packages/kbn-search-api-panels"], "@kbn/search-api-panels/*": ["packages/kbn-search-api-panels/*"], "@kbn/search-assistant": ["x-pack/plugins/search_assistant"], @@ -1526,6 +1530,8 @@ "@kbn/search-playground/*": ["x-pack/plugins/search_playground/*"], "@kbn/search-response-warnings": ["packages/kbn-search-response-warnings"], "@kbn/search-response-warnings/*": ["packages/kbn-search-response-warnings/*"], + "@kbn/search-shared-ui": ["x-pack/packages/search/shared_ui"], + "@kbn/search-shared-ui/*": ["x-pack/packages/search/shared_ui/*"], "@kbn/search-types": ["packages/kbn-search-types"], "@kbn/search-types/*": ["packages/kbn-search-types/*"], "@kbn/searchprofiler-plugin": ["x-pack/plugins/searchprofiler"], @@ -1996,7 +2002,9 @@ "@kbn/zod-helpers/*": ["packages/kbn-zod-helpers/*"], // END AUTOMATED PACKAGE LISTING // Allows for importing from `kibana` package for the exported types. - "@emotion/core": ["typings/@emotion"] + "@emotion/core": [ + "typings/@emotion" + ] }, // Support .tsx files and transform JSX into calls to React.createElement "jsx": "react", @@ -2070,4 +2078,4 @@ "@kbn/ambient-storybook-types" ] } -} +} \ No newline at end of file diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index 97aa05deb4a42..72e35fb16de2f 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -104,6 +104,7 @@ "xpack.rollupJobs": ["packages/rollup", "plugins/rollup"], "xpack.runtimeFields": "plugins/runtime_fields", "xpack.screenshotting": "plugins/screenshotting", + "xpack.searchSharedUI": "packages/search/shared_ui", "xpack.searchHomepage": "plugins/search_homepage", "xpack.searchIndices": "plugins/search_indices", "xpack.searchNotebooks": "plugins/search_notebooks", diff --git a/x-pack/packages/search/shared_ui/README.md b/x-pack/packages/search/shared_ui/README.md new file mode 100644 index 0000000000000..d73336a303e6a --- /dev/null +++ b/x-pack/packages/search/shared_ui/README.md @@ -0,0 +1,3 @@ +# @kbn/search-shared-ui + +Contains form components used within search indices plugin. diff --git a/x-pack/packages/search/shared_ui/index.ts b/x-pack/packages/search/shared_ui/index.ts new file mode 100644 index 0000000000000..786fc67f4ea6d --- /dev/null +++ b/x-pack/packages/search/shared_ui/index.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 * from './src/form_info_field/form_info_field'; diff --git a/x-pack/packages/search/shared_ui/jest.config.js b/x-pack/packages/search/shared_ui/jest.config.js new file mode 100644 index 0000000000000..2b866207d6313 --- /dev/null +++ b/x-pack/packages/search/shared_ui/jest.config.js @@ -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. + */ + +module.exports = { + coverageDirectory: '/target/kibana-coverage/jest/x-pack/packages/search/shared_ui', + coverageReporters: ['text', 'html'], + collectCoverageFrom: ['/x-pack/packages/search/shared_ui/**/*.{ts,tsx}'], + preset: '@kbn/test', + rootDir: '../../../..', + roots: ['/x-pack/packages/search/shared_ui'], +}; diff --git a/x-pack/packages/search/shared_ui/kibana.jsonc b/x-pack/packages/search/shared_ui/kibana.jsonc new file mode 100644 index 0000000000000..aedc015c1d6fa --- /dev/null +++ b/x-pack/packages/search/shared_ui/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-browser", + "id": "@kbn/search-shared-ui", + "owner": "@elastic/search-kibana" +} \ No newline at end of file diff --git a/x-pack/packages/search/shared_ui/package.json b/x-pack/packages/search/shared_ui/package.json new file mode 100644 index 0000000000000..d1456579cd050 --- /dev/null +++ b/x-pack/packages/search/shared_ui/package.json @@ -0,0 +1,6 @@ +{ + "name": "@kbn/search-shared-ui", + "private": true, + "version": "1.0.0", + "license": "Elastic License 2.0" +} diff --git a/x-pack/packages/search/shared_ui/src/form_info_field/form_info_field.tsx b/x-pack/packages/search/shared_ui/src/form_info_field/form_info_field.tsx new file mode 100644 index 0000000000000..89e14e72454c6 --- /dev/null +++ b/x-pack/packages/search/shared_ui/src/form_info_field/form_info_field.tsx @@ -0,0 +1,88 @@ +/* + * 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 { + EuiButtonIcon, + EuiCopy, + EuiFlexGroup, + EuiFlexItem, + EuiTitle, + useEuiTheme, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +interface FormInfoFieldProps { + actions?: React.ReactNode[]; + label?: string; + value: string; + copyValue?: string; + dataTestSubj?: string; +} + +export const FormInfoField: React.FC = ({ + actions = [], + label, + value, + copyValue, + dataTestSubj, +}) => { + const { euiTheme } = useEuiTheme(); + + return ( + + {label && ( + + +

{label}

+
+
+ )} + + + {value} + + + + + {(copy) => ( + + )} + + + {actions.map((action, index) => ( + + {action} + + ))} +
+ ); +}; diff --git a/x-pack/packages/search/shared_ui/tsconfig.json b/x-pack/packages/search/shared_ui/tsconfig.json new file mode 100644 index 0000000000000..b4df7a5231281 --- /dev/null +++ b/x-pack/packages/search/shared_ui/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node", + "react", + ] + }, + "include": [ + "index.ts", + "**/*.ts", + "**/*.tsx", + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [ + "@kbn/i18n", + ], +} diff --git a/x-pack/packages/security/api_key_management/src/components/api_key_flyout.tsx b/x-pack/packages/security/api_key_management/src/components/api_key_flyout.tsx index 82c37b72dd41c..4a8fa74095957 100644 --- a/x-pack/packages/security/api_key_management/src/components/api_key_flyout.tsx +++ b/x-pack/packages/security/api_key_management/src/components/api_key_flyout.tsx @@ -96,6 +96,7 @@ interface CommonApiKeyFlyoutProps { http?: CoreStart['http']; currentUser?: AuthenticatedUser; isLoadingCurrentUser?: boolean; + defaultName?: string; defaultMetadata?: string; defaultRoleDescriptors?: string; defaultExpiration?: string; @@ -172,6 +173,7 @@ export const ApiKeyFlyout: FunctionComponent = ({ defaultExpiration, defaultMetadata, defaultRoleDescriptors, + defaultName, apiKey, canManageCrossClusterApiKeys = false, readOnly = false, @@ -250,6 +252,12 @@ export const ApiKeyFlyout: FunctionComponent = ({ } }, [defaultRoleDescriptors]); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { + if (defaultName && !apiKey) { + formik.setFieldValue('name', defaultName); + } + }, [defaultName]); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { if (defaultMetadata && !apiKey) { formik.setFieldValue('metadata', defaultMetadata); diff --git a/x-pack/plugins/search_indices/kibana.jsonc b/x-pack/plugins/search_indices/kibana.jsonc index 63fbf63609dff..13abaf63cbbe3 100644 --- a/x-pack/plugins/search_indices/kibana.jsonc +++ b/x-pack/plugins/search_indices/kibana.jsonc @@ -24,4 +24,4 @@ "esUiShared" ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/search_indices/public/application.tsx b/x-pack/plugins/search_indices/public/application.tsx index c196020319589..e3f537998d5ea 100644 --- a/x-pack/plugins/search_indices/public/application.tsx +++ b/x-pack/plugins/search_indices/public/application.tsx @@ -13,6 +13,7 @@ import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import { I18nProvider } from '@kbn/i18n-react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { SearchApiKeyProvider } from '@kbn/search-api-keys-components'; import { UsageTrackerContextProvider } from './contexts/usage_tracker_context'; import { SearchIndicesServicesContextDeps } from './types'; @@ -29,7 +30,9 @@ export const renderApp = async ( - + + + diff --git a/x-pack/plugins/search_indices/public/components/connection_details/connection_details.tsx b/x-pack/plugins/search_indices/public/components/connection_details/connection_details.tsx index d7ce8f308b683..b79a0e1f16828 100644 --- a/x-pack/plugins/search_indices/public/components/connection_details/connection_details.tsx +++ b/x-pack/plugins/search_indices/public/components/connection_details/connection_details.tsx @@ -7,67 +7,22 @@ import React from 'react'; -import { - EuiButtonIcon, - EuiCopy, - EuiFlexGroup, - EuiFlexItem, - EuiTitle, - useEuiTheme, -} from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; +import { FormInfoField } from '@kbn/search-shared-ui'; import { useElasticsearchUrl } from '../../hooks/use_elasticsearch_url'; export const ConnectionDetails: React.FC = () => { - const { euiTheme } = useEuiTheme(); const elasticsearchUrl = useElasticsearchUrl(); return ( - - - -

- -

-
-
- -

- {elasticsearchUrl} -

-
- - - {(copy) => ( - - )} - - -
+ ); }; diff --git a/x-pack/plugins/search_indices/public/components/index_documents/add_documents_code_example.tsx b/x-pack/plugins/search_indices/public/components/index_documents/add_documents_code_example.tsx index c629903b38444..aa8fd525c8a44 100644 --- a/x-pack/plugins/search_indices/public/components/index_documents/add_documents_code_example.tsx +++ b/x-pack/plugins/search_indices/public/components/index_documents/add_documents_code_example.tsx @@ -11,6 +11,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { TryInConsoleButton } from '@kbn/try-in-console'; +import { useSearchApiKey } from '@kbn/search-api-keys-components'; import { useKibana } from '../../hooks/use_kibana'; import { IngestCodeSnippetParameters } from '../../types'; import { LanguageSelector } from '../shared/language_selector'; @@ -58,6 +59,7 @@ export const AddDocumentsCodeExample = ({ // TODO: implement smart document generation return generateSampleDocument(codeSampleMappings); }, [codeSampleMappings]); + const { apiKey, apiKeyIsVisible } = useSearchApiKey(); const codeParams: IngestCodeSnippetParameters = useMemo(() => { return { indexName, @@ -65,8 +67,17 @@ export const AddDocumentsCodeExample = ({ sampleDocument, indexHasMappings, mappingProperties: codeSampleMappings, + apiKey: apiKeyIsVisible && apiKey ? apiKey : undefined, }; - }, [indexName, elasticsearchUrl, sampleDocument, codeSampleMappings, indexHasMappings]); + }, [ + indexName, + elasticsearchUrl, + sampleDocument, + codeSampleMappings, + indexHasMappings, + apiKeyIsVisible, + apiKey, + ]); return ( { - - + + - {/* TODO: API KEY */} + + + diff --git a/x-pack/plugins/search_indices/public/components/start/create_index_code.tsx b/x-pack/plugins/search_indices/public/components/start/create_index_code.tsx index e8efe1c3b7e66..401173fecc0da 100644 --- a/x-pack/plugins/search_indices/public/components/start/create_index_code.tsx +++ b/x-pack/plugins/search_indices/public/components/start/create_index_code.tsx @@ -5,10 +5,11 @@ * 2.0. */ import React, { useCallback, useMemo } from 'react'; -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiSpacer, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { TryInConsoleButton } from '@kbn/try-in-console'; +import { ApiKeyForm, useSearchApiKey } from '@kbn/search-api-keys-components'; import { AnalyticsEvents } from '../../analytics/constants'; import { Languages, AvailableLanguages, LanguageOptions } from '../../code_examples'; @@ -47,18 +48,48 @@ export const CreateIndexCodeView = ({ [usageTracker, changeCodingLanguage] ); const elasticsearchUrl = useElasticsearchUrl(); + const { apiKey, apiKeyIsVisible } = useSearchApiKey(); + const codeParams = useMemo(() => { return { indexName: createIndexForm.indexName || undefined, elasticsearchURL: elasticsearchUrl, + apiKey: apiKeyIsVisible && apiKey ? apiKey : undefined, }; - }, [createIndexForm.indexName, elasticsearchUrl]); + }, [createIndexForm.indexName, elasticsearchUrl, apiKeyIsVisible, apiKey]); const selectedCodeExample = useMemo(() => { return selectedCodeExamples[selectedLanguage]; }, [selectedLanguage, selectedCodeExamples]); return ( + + + + + +
+ {i18n.translate('xpack.searchIndices.startPage.codeView.apiKeyTitle', { + defaultMessage: 'Copy your API key', + })} +
+
+
+ + +

+ {i18n.translate('xpack.searchIndices.startPage.codeView.apiKeyDescription', { + defaultMessage: + 'Make sure you keep it somewhere safe. You won’t be able to retrieve it later.', + })} +

+
+
+
+ + +
+
)} { - const { name, expiresInDays, indices } = request.body; - const { client } = (await context.core).elasticsearch; - - const apiKey = await client.asCurrentUser.security.createApiKey({ - name, - expiration: `${expiresInDays}d`, - role_descriptors: { - [`playground-${name}-role`]: { - cluster: [], - indices: [ - { - names: indices, - privileges: ['read'], - }, - ], - }, - }, - }); - - return response.ok({ - body: { apiKey }, - headers: { 'content-type': 'application/json' }, - }); - }) - ); - // SECURITY: We don't apply any authorization tags to this route because all actions performed // on behalf of the user making the request and governed by the user's own cluster privileges. router.get( diff --git a/x-pack/test_serverless/functional/page_objects/index.ts b/x-pack/test_serverless/functional/page_objects/index.ts index 6874f1e7aa621..0c6b776433b0c 100644 --- a/x-pack/test_serverless/functional/page_objects/index.ts +++ b/x-pack/test_serverless/functional/page_objects/index.ts @@ -23,6 +23,7 @@ import { SvlIngestPipelines } from './svl_ingest_pipelines'; import { SvlSearchHomePageProvider } from './svl_search_homepage'; import { SvlSearchIndexDetailPageProvider } from './svl_search_index_detail_page'; import { SvlSearchElasticsearchStartPageProvider } from './svl_search_elasticsearch_start_page'; +import { SvlApiKeysProvider } from './svl_api_keys'; export const pageObjects = { ...xpackFunctionalPageObjects, @@ -43,4 +44,5 @@ export const pageObjects = { svlSearchHomePage: SvlSearchHomePageProvider, svlSearchIndexDetailPage: SvlSearchIndexDetailPageProvider, svlSearchElasticsearchStartPage: SvlSearchElasticsearchStartPageProvider, + svlApiKeys: SvlApiKeysProvider, }; diff --git a/x-pack/test_serverless/functional/page_objects/svl_api_keys.ts b/x-pack/test_serverless/functional/page_objects/svl_api_keys.ts new file mode 100644 index 0000000000000..2dc65cc97cd3e --- /dev/null +++ b/x-pack/test_serverless/functional/page_objects/svl_api_keys.ts @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../ftr_provider_context'; + +const APIKEY_MASK = '•'.repeat(60); + +export function SvlApiKeysProvider({ getService, getPageObjects }: FtrProviderContext) { + const testSubjects = getService('testSubjects'); + const browser = getService('browser'); + const pageObjects = getPageObjects(['common', 'apiKeys']); + const retry = getService('retry'); + const es = getService('es'); + + const getAPIKeyFromSessionStorage = async () => { + const sessionStorageKey = await browser.getSessionStorageItem('searchApiKey'); + return sessionStorageKey && JSON.parse(sessionStorageKey); + }; + + return { + async clearAPIKeySessionStorage() { + await browser.clearSessionStorage(); + }, + + async expectAPIKeyAvailable() { + await testSubjects.existOrFail('apiKeyFormAPIKey'); + await retry.try(async () => { + expect(await testSubjects.getVisibleText('apiKeyFormAPIKey')).to.be(APIKEY_MASK); + }); + await testSubjects.click('showAPIKeyButton'); + let apiKey; + await retry.try(async () => { + apiKey = await testSubjects.getVisibleText('apiKeyFormAPIKey'); + expect(apiKey).to.be.a('string'); + expect(apiKey.length).to.be(60); + expect(apiKey).to.not.be(APIKEY_MASK); + }); + const sessionStorageKey = await getAPIKeyFromSessionStorage(); + expect(sessionStorageKey.encoded).to.eql(apiKey); + }, + + async getAPIKeyFromSessionStorage() { + return getAPIKeyFromSessionStorage(); + }, + + async getAPIKeyFromUI() { + let apiKey = ''; + await retry.try(async () => { + apiKey = await testSubjects.getVisibleText('apiKeyFormAPIKey'); + expect(apiKey).to.not.be(APIKEY_MASK); + }); + expect(apiKey).to.be.a('string'); + return apiKey; + }, + + async invalidateAPIKey(apiKeyId: string) { + await es.security.invalidateApiKey({ ids: [apiKeyId] }); + }, + + async createAPIKey() { + await es.security.createApiKey({ + name: 'test-api-key', + role_descriptors: {}, + }); + }, + + async expectAPIKeyCreate() { + await testSubjects.existOrFail('apiKeyFormAPIKey'); + await retry.try(async () => { + expect(await testSubjects.getVisibleText('apiKeyFormAPIKey')).to.be(APIKEY_MASK); + }); + await testSubjects.click('showAPIKeyButton'); + await retry.try(async () => { + const apiKey = await testSubjects.getVisibleText('apiKeyFormAPIKey'); + expect(apiKey).to.be.a('string'); + expect(apiKey.length).to.be(60); + expect(apiKey).to.not.be(APIKEY_MASK); + }); + }, + + async deleteAPIKeys() { + const { api_keys: apiKeys } = await es.security.getApiKey(); + await es.security.invalidateApiKey({ ids: apiKeys.map((key) => key.id) }); + }, + + async expectCreateApiKeyAction() { + await testSubjects.existOrFail('createAPIKeyButton'); + }, + + async createApiKeyFromFlyout() { + const apiKeyName = 'Happy API Key'; + await testSubjects.click('createAPIKeyButton'); + expect(await pageObjects.apiKeys.getFlyoutTitleText()).to.be('Create API key'); + + await pageObjects.apiKeys.setApiKeyName(apiKeyName); + await pageObjects.apiKeys.clickSubmitButtonOnApiKeyFlyout(); + }, + + async expectNoPermissionsMessage() { + await testSubjects.existOrFail('apiKeyFormNoUserPrivileges'); + }, + }; +} diff --git a/x-pack/test_serverless/functional/page_objects/svl_search_elasticsearch_start_page.ts b/x-pack/test_serverless/functional/page_objects/svl_search_elasticsearch_start_page.ts index 798d396258e75..4c6822c420fdb 100644 --- a/x-pack/test_serverless/functional/page_objects/svl_search_elasticsearch_start_page.ts +++ b/x-pack/test_serverless/functional/page_objects/svl_search_elasticsearch_start_page.ts @@ -90,5 +90,11 @@ export function SvlSearchElasticsearchStartPageProvider({ getService }: FtrProvi ); expect(await testSubjects.getAttribute('startO11yTrialBtn', 'target')).equal('_blank'); }, + async expectAPIKeyVisibleInCodeBlock(apiKey: string) { + await testSubjects.existOrFail('createIndex-code-block'); + await retry.try(async () => { + expect(await testSubjects.getVisibleText('createIndex-code-block')).to.contain(apiKey); + }); + }, }; } diff --git a/x-pack/test_serverless/functional/page_objects/svl_search_index_detail_page.ts b/x-pack/test_serverless/functional/page_objects/svl_search_index_detail_page.ts index 7101571036c4e..dabc8dffac09e 100644 --- a/x-pack/test_serverless/functional/page_objects/svl_search_index_detail_page.ts +++ b/x-pack/test_serverless/functional/page_objects/svl_search_index_detail_page.ts @@ -157,5 +157,12 @@ export function SvlSearchIndexDetailPageProvider({ getService }: FtrProviderCont await testSubjects.existOrFail('tryInConsoleButton'); await testSubjects.click('tryInConsoleButton'); }, + + async expectAPIKeyToBeVisibleInCodeBlock(apiKey: string) { + await testSubjects.existOrFail('ingestDataCodeExample-code-block'); + expect(await testSubjects.getVisibleText('ingestDataCodeExample-code-block')).to.contain( + apiKey + ); + }, }; } diff --git a/x-pack/test_serverless/functional/test_suites/search/elasticsearch_start.ts b/x-pack/test_serverless/functional/test_suites/search/elasticsearch_start.ts index f6362a409658e..9e83057544003 100644 --- a/x-pack/test_serverless/functional/test_suites/search/elasticsearch_start.ts +++ b/x-pack/test_serverless/functional/test_suites/search/elasticsearch_start.ts @@ -5,6 +5,7 @@ * 2.0. */ +import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; import { testHasEmbeddedConsole } from './embedded_console'; @@ -14,10 +15,12 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { 'svlCommonPage', 'embeddedConsole', 'svlSearchElasticsearchStartPage', + 'svlApiKeys', ]); const svlSearchNavigation = getService('svlSearchNavigation'); const esDeleteAllIndices = getService('esDeleteAllIndices'); const es = getService('es'); + const browser = getService('browser'); const deleteAllTestIndices = async () => { await esDeleteAllIndices(['search-*', 'test-*']); @@ -27,6 +30,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { describe('developer', function () { before(async () => { await pageObjects.svlCommonPage.loginWithRole('developer'); + await pageObjects.svlApiKeys.deleteAPIKeys(); }); after(async () => { await deleteAllTestIndices(); @@ -82,6 +86,58 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await pageObjects.svlSearchElasticsearchStartPage.expectCreateIndexUIView(); }); + it('should show the api key in code view', async () => { + await pageObjects.svlSearchElasticsearchStartPage.expectToBeOnStartPage(); + await pageObjects.svlSearchElasticsearchStartPage.clickCodeViewButton(); + await pageObjects.svlApiKeys.expectAPIKeyAvailable(); + const apiKeyUI = await pageObjects.svlApiKeys.getAPIKeyFromUI(); + const apiKeySession = await pageObjects.svlApiKeys.getAPIKeyFromSessionStorage(); + + expect(apiKeyUI).to.eql(apiKeySession.encoded); + + // check that when browser is refreshed, the api key is still available + await browser.refresh(); + await pageObjects.svlSearchElasticsearchStartPage.clickCodeViewButton(); + await pageObjects.svlApiKeys.expectAPIKeyAvailable(); + const refreshBrowserApiKeyUI = await pageObjects.svlApiKeys.getAPIKeyFromUI(); + expect(refreshBrowserApiKeyUI).to.eql(apiKeyUI); + + // check that when api key is invalidated, a new one is generated + await pageObjects.svlApiKeys.invalidateAPIKey(apiKeySession.id); + await browser.refresh(); + await pageObjects.svlSearchElasticsearchStartPage.clickCodeViewButton(); + await pageObjects.svlApiKeys.expectAPIKeyAvailable(); + const newApiKeyUI = await pageObjects.svlApiKeys.getAPIKeyFromUI(); + expect(newApiKeyUI).to.not.eql(apiKeyUI); + await pageObjects.svlSearchElasticsearchStartPage.expectAPIKeyVisibleInCodeBlock( + newApiKeyUI + ); + }); + + it('should explicitly ask to create api key when project already has an apikey', async () => { + await pageObjects.svlApiKeys.clearAPIKeySessionStorage(); + await pageObjects.svlApiKeys.createAPIKey(); + await pageObjects.svlSearchElasticsearchStartPage.expectToBeOnStartPage(); + await pageObjects.svlSearchElasticsearchStartPage.clickCodeViewButton(); + await pageObjects.svlApiKeys.createApiKeyFromFlyout(); + await pageObjects.svlApiKeys.expectAPIKeyAvailable(); + }); + + it('Same API Key should be present on start page and index detail view', async () => { + await pageObjects.svlSearchElasticsearchStartPage.clickCodeViewButton(); + await pageObjects.svlApiKeys.expectAPIKeyAvailable(); + const apiKeyUI = await pageObjects.svlApiKeys.getAPIKeyFromUI(); + + await pageObjects.svlSearchElasticsearchStartPage.clickUIViewButton(); + await pageObjects.svlSearchElasticsearchStartPage.clickCreateIndexButton(); + await pageObjects.svlSearchElasticsearchStartPage.expectToBeOnIndexDetailsPage(); + + await pageObjects.svlApiKeys.expectAPIKeyAvailable(); + const indexDetailsApiKey = await pageObjects.svlApiKeys.getAPIKeyFromUI(); + + expect(apiKeyUI).to.eql(indexDetailsApiKey); + }); + it('should have file upload link', async () => { await pageObjects.svlSearchElasticsearchStartPage.expectToBeOnStartPage(); await pageObjects.svlSearchElasticsearchStartPage.clickFileUploadLink(); @@ -93,32 +149,41 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await pageObjects.svlSearchElasticsearchStartPage.expectAnalyzeLogsLink(); await pageObjects.svlSearchElasticsearchStartPage.expectO11yTrialLink(); }); - }); - describe('viewer', function () { - before(async () => { - await pageObjects.svlCommonPage.loginAsViewer(); - await deleteAllTestIndices(); - }); - beforeEach(async () => { - await svlSearchNavigation.navigateToElasticsearchStartPage(); - }); - after(async () => { - await deleteAllTestIndices(); - }); - it('should default to code view when lacking create index permissions', async () => { - await pageObjects.svlSearchElasticsearchStartPage.expectToBeOnStartPage(); - await pageObjects.svlSearchElasticsearchStartPage.expectCreateIndexCodeView(); - await pageObjects.svlSearchElasticsearchStartPage.clickUIViewButton(); - await pageObjects.svlSearchElasticsearchStartPage.expectCreateIndexUIView(); - await pageObjects.svlSearchElasticsearchStartPage.expectCreateIndexButtonToBeDisabled(); - }); + describe('viewer', function () { + before(async () => { + await pageObjects.svlCommonPage.loginAsViewer(); + await deleteAllTestIndices(); + }); + beforeEach(async () => { + await svlSearchNavigation.navigateToElasticsearchStartPage(); + }); + after(async () => { + await deleteAllTestIndices(); + }); - it('should redirect to index details when index is created via API', async () => { - await pageObjects.svlSearchElasticsearchStartPage.expectToBeOnStartPage(); - await pageObjects.svlSearchElasticsearchStartPage.expectCreateIndexCodeView(); - await es.indices.create({ index: 'test-my-api-index' }); - await pageObjects.svlSearchElasticsearchStartPage.expectToBeOnIndexDetailsPage(); + it('should default to code view when lacking create index permissions', async () => { + await pageObjects.svlSearchElasticsearchStartPage.expectToBeOnStartPage(); + await pageObjects.svlSearchElasticsearchStartPage.expectCreateIndexCodeView(); + await pageObjects.svlSearchElasticsearchStartPage.clickUIViewButton(); + await pageObjects.svlSearchElasticsearchStartPage.expectCreateIndexUIView(); + await pageObjects.svlSearchElasticsearchStartPage.expectCreateIndexButtonToBeDisabled(); + }); + + it('should not create an API key if the user only has viewer permissions', async () => { + await pageObjects.svlSearchElasticsearchStartPage.expectToBeOnStartPage(); + await pageObjects.svlSearchElasticsearchStartPage.clickCodeViewButton(); + await pageObjects.svlApiKeys.expectNoPermissionsMessage(); + const apiKey = await pageObjects.svlApiKeys.getAPIKeyFromSessionStorage(); + expect(apiKey).to.be(null); + }); + + it('should redirect to index details when index is created via API', async () => { + await pageObjects.svlSearchElasticsearchStartPage.expectToBeOnStartPage(); + await pageObjects.svlSearchElasticsearchStartPage.expectCreateIndexCodeView(); + await es.indices.create({ index: 'test-my-api-index' }); + await pageObjects.svlSearchElasticsearchStartPage.expectToBeOnIndexDetailsPage(); + }); }); }); }); diff --git a/x-pack/test_serverless/functional/test_suites/search/search_index_detail.ts b/x-pack/test_serverless/functional/test_suites/search/search_index_detail.ts index 7192eca9d13c3..b6247f90b37c5 100644 --- a/x-pack/test_serverless/functional/test_suites/search/search_index_detail.ts +++ b/x-pack/test_serverless/functional/test_suites/search/search_index_detail.ts @@ -12,6 +12,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { 'svlCommonPage', 'embeddedConsole', 'svlSearchIndexDetailPage', + 'svlApiKeys', ]); const svlSearchNavigation = getService('svlSearchNavigation'); const es = getService('es'); @@ -22,6 +23,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { describe('Search index detail page', () => { before(async () => { await pageObjects.svlCommonPage.loginWithRole('developer'); + await pageObjects.svlApiKeys.deleteAPIKeys(); }); after(async () => { await esDeleteAllIndices(indexName); @@ -82,6 +84,12 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await pageObjects.embeddedConsole.clickEmbeddedConsoleControlBar(); }); + it('should show api key', async () => { + await pageObjects.svlApiKeys.expectAPIKeyAvailable(); + const apiKey = await pageObjects.svlApiKeys.getAPIKeyFromUI(); + await pageObjects.svlSearchIndexDetailPage.expectAPIKeyToBeVisibleInCodeBlock(apiKey); + }); + it('back to indices button should redirect to list page', async () => { await pageObjects.svlSearchIndexDetailPage.expectBackToIndicesButtonExists(); await pageObjects.svlSearchIndexDetailPage.clickBackToIndicesButton(); diff --git a/yarn.lock b/yarn.lock index 8146185716b37..236319c030c55 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6283,6 +6283,14 @@ version "0.0.0" uid "" +"@kbn/search-api-keys-components@link:packages/kbn-search-api-keys-components": + version "0.0.0" + uid "" + +"@kbn/search-api-keys-server@link:packages/kbn-search-api-keys-server": + version "0.0.0" + uid "" + "@kbn/search-api-panels@link:packages/kbn-search-api-panels": version "0.0.0" uid "" @@ -6335,6 +6343,10 @@ version "0.0.0" uid "" +"@kbn/search-shared-ui@link:x-pack/packages/search/shared_ui": + version "0.0.0" + uid "" + "@kbn/search-types@link:packages/kbn-search-types": version "0.0.0" uid ""