[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.
<img width="1255" alt="Screenshot 2024-09-27 at 20 52 52"
src="https://github.com/user-attachments/assets/dc5bcd39-7fe6-433c-8aaa-ad3578a68b62">
<img width="1248" alt="Screenshot 2024-09-27 at 20 52 39"
src="https://github.com/user-attachments/assets/d760c163-9017-4f57-ba1a-38ee8ee21534">
<img width="676" alt="Screenshot 2024-09-27 at 20 52 28"
src="https://github.com/user-attachments/assets/e908d20a-7e0c-4f3b-9ea2-8e2d1a74c9eb">
This commit is contained in:
Yan Savitski 2024-10-02 10:45:20 +02:00 committed by GitHub
parent d1f24b050b
commit c5aa739914
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 1208 additions and 126 deletions

3
.github/CODEOWNERS vendored
View file

@ -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

View file

@ -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",

View file

@ -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",

View file

@ -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.

View file

@ -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';

View file

@ -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: ['<rootDir>/packages/kbn-search-api-keys-components'],
coverageDirectory:
'<rootDir>/target/kibana-coverage/jest/packages/kbn-search-api-keys-components',
coverageReporters: ['text', 'html'],
collectCoverageFrom: [
'<rootDir>/packages/kbn-search-api-keys-components/public/{components,hooks}/**/*.{ts,tsx}',
],
};

View file

@ -0,0 +1,5 @@
{
"type": "shared-browser",
"id": "@kbn/search-api-keys-components",
"owner": "@elastic/search-kibana"
}

View file

@ -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"
}

View file

@ -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<ApiKeyFlyoutProps, 'onCancel'> & {
onSuccess?: (createApiKeyResponse: SecurityCreateApiKeyResponse) => void;
};
export const ApiKeyFlyoutWrapper: React.FC<ApiKeyFlyoutWrapperProps> = ({
onCancel,
onSuccess,
}) => {
return <ApiKeyFlyout onCancel={onCancel} onSuccess={onSuccess} defaultName={API_KEY_NAME} />;
};

View file

@ -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<ApiKeyFormProps> = ({ 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 (
<FormInfoField
label={hasTitle ? titleLocale : undefined}
value={displayedApiKey}
copyValue={apiKey}
dataTestSubj="apiKeyFormAPIKey"
actions={[
<EuiButtonIcon
iconType={apiKeyIsVisible ? 'eyeClosed' : 'eye'}
color="success"
onClick={toggleApiKeyVisibility}
data-test-subj="showAPIKeyButton"
aria-label={i18n.translate('searchApiKeysComponents.apiKeyForm.showApiKey', {
defaultMessage: 'Show API Key',
})}
/>,
]}
/>
);
}
return (
<EuiFlexGroup alignItems="center" gutterSize="s" justifyContent="flexStart" responsive={false}>
{hasTitle && (
<EuiFlexItem grow={0}>
<EuiTitle size="xxxs" css={{ whiteSpace: 'nowrap' }}>
<h6>{titleLocale}</h6>
</EuiTitle>
</EuiFlexItem>
)}
{status === Status.showUserPrivilegesError && (
<EuiFlexItem grow={0}>
<EuiBadge data-test-subj="apiKeyFormNoUserPrivileges">
{i18n.translate('searchApiKeysComponents.apiKeyForm.noUserPrivileges', {
defaultMessage: "You don't have access to manage API keys",
})}
</EuiBadge>
</EuiFlexItem>
)}
{status === Status.showCreateButton && (
<EuiFlexItem grow={0}>
<EuiButton
color="primary"
size="s"
iconSide="left"
iconType="key"
onClick={() => setShowFlyout(true)}
data-test-subj="createAPIKeyButton"
>
<FormattedMessage
id="searchApiKeysComponents.apiKeyForm.createButton"
defaultMessage="Create an API Key"
/>
</EuiButton>
{showFlyout && (
<ApiKeyFlyoutWrapper onCancel={() => setShowFlyout(false)} onSuccess={updateApiKey} />
)}
</EuiFlexItem>
)}
</EuiFlexGroup>
);
};

View file

@ -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',
}

View file

@ -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;
};

View file

@ -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<APIKeyContext>({
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<APIKeyCreationResponse | undefined>({
mutationFn: async () => {
try {
if (!http?.post) {
throw new Error('HTTP service is unavailable');
}
return await http.post<APIKeyCreationResponse>(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 <ApiKeyContext.Provider value={value}>{children}</ApiKeyContext.Provider>;
};

View file

@ -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',
}

View file

@ -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/**/*",
]
}

View file

@ -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.

View file

@ -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';

View file

@ -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: ['<rootDir>/packages/kbn-search-api-keys-server'],
coverageDirectory: '<rootDir>/target/kibana-coverage/jest/packages/kbn-search-api-keys-server',
coverageReporters: ['text', 'html'],
collectCoverageFrom: ['<rootDir>/packages/kbn-search-api-keys-server/**/*.{ts,tsx}'],
};

View file

@ -0,0 +1,5 @@
{
"type": "shared-server",
"id": "@kbn/search-api-keys-server",
"owner": "@elastic/search-kibana"
}

View file

@ -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"
}

View file

@ -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<APIKeyCreationResponse> {
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;
}
}

View file

@ -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<GetApiKeyResponse> {
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;
}
}

View file

@ -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<boolean> {
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<boolean> {
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;
}
}

View file

@ -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,
});
}
}
);
}

View file

@ -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/**/*",
]
}

View file

@ -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<APIKey, 'id' | 'name' | 'expiration'> {
api_key: string;
encoded: string;
}
export type GetApiKeyResponse = APIKey;

View file

@ -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"
]
}
}
}

View file

@ -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",

View file

@ -0,0 +1,3 @@
# @kbn/search-shared-ui
Contains form components used within search indices plugin.

View file

@ -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';

View file

@ -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: '<rootDir>/target/kibana-coverage/jest/x-pack/packages/search/shared_ui',
coverageReporters: ['text', 'html'],
collectCoverageFrom: ['<rootDir>/x-pack/packages/search/shared_ui/**/*.{ts,tsx}'],
preset: '@kbn/test',
rootDir: '../../../..',
roots: ['<rootDir>/x-pack/packages/search/shared_ui'],
};

View file

@ -0,0 +1,5 @@
{
"type": "shared-browser",
"id": "@kbn/search-shared-ui",
"owner": "@elastic/search-kibana"
}

View file

@ -0,0 +1,6 @@
{
"name": "@kbn/search-shared-ui",
"private": true,
"version": "1.0.0",
"license": "Elastic License 2.0"
}

View file

@ -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<FormInfoFieldProps> = ({
actions = [],
label,
value,
copyValue,
dataTestSubj,
}) => {
const { euiTheme } = useEuiTheme();
return (
<EuiFlexGroup gutterSize="s" alignItems="center">
{label && (
<EuiFlexItem grow={false}>
<EuiTitle size="xxxs">
<h1>{label}</h1>
</EuiTitle>
</EuiFlexItem>
)}
<EuiFlexItem grow={0} css={{ whiteSpace: 'nowrap', overflow: 'hidden' }}>
<code
data-test-subj={dataTestSubj}
style={{
color: euiTheme.colors.successText,
padding: `${euiTheme.size.s} ${euiTheme.size.m}`,
backgroundColor: euiTheme.colors.lightestShade,
textOverflow: 'ellipsis',
overflow: 'hidden',
borderRadius: euiTheme.border.radius.small,
fontWeight: euiTheme.font.weight.bold,
fontSize: euiTheme.size.m,
}}
>
{value}
</code>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiCopy
textToCopy={copyValue ?? value}
afterMessage={i18n.translate('xpack.searchSharedUI.formInfoField.copyMessage', {
defaultMessage: 'Copied',
})}
>
{(copy) => (
<EuiButtonIcon
onClick={copy}
iconType="copy"
aria-label={i18n.translate('xpack.searchSharedUI.formInfoField.copyMessage', {
defaultMessage: 'Copy to clipboard',
})}
/>
)}
</EuiCopy>
</EuiFlexItem>
{actions.map((action, index) => (
<EuiFlexItem key={index} grow={false}>
{action}
</EuiFlexItem>
))}
</EuiFlexGroup>
);
};

View file

@ -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",
],
}

View file

@ -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<ApiKeyFlyoutProps> = ({
defaultExpiration,
defaultMetadata,
defaultRoleDescriptors,
defaultName,
apiKey,
canManageCrossClusterApiKeys = false,
readOnly = false,
@ -250,6 +252,12 @@ export const ApiKeyFlyout: FunctionComponent<ApiKeyFlyoutProps> = ({
}
}, [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);

View file

@ -24,4 +24,4 @@
"esUiShared"
]
}
}
}

View file

@ -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 (
<UsageTrackerContextProvider usageCollection={services.usageCollection}>
<I18nProvider>
<QueryClientProvider client={queryClient}>
<App />
<SearchApiKeyProvider>
<App />
</SearchApiKeyProvider>
</QueryClientProvider>
</I18nProvider>
</UsageTrackerContextProvider>

View file

@ -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 (
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>
<EuiTitle size="xxxs">
<h1>
<FormattedMessage
id="xpack.searchIndices.connectionDetails.endpointTitle"
defaultMessage="Elasticsearch URL"
/>
</h1>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem css={{ whiteSpace: 'nowrap', overflow: 'hidden' }}>
<p
data-test-subj="connectionDetailsEndpoint"
css={{
color: euiTheme.colors.successText,
padding: `${euiTheme.size.s} ${euiTheme.size.m}`,
backgroundColor: euiTheme.colors.lightestShade,
textOverflow: 'ellipsis',
overflow: 'hidden',
}}
>
{elasticsearchUrl}
</p>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiCopy
textToCopy={elasticsearchUrl}
afterMessage={i18n.translate('xpack.searchIndices.connectionDetails.copyMessage', {
defaultMessage: 'Copied',
})}
>
{(copy) => (
<EuiButtonIcon
onClick={copy}
iconType="copy"
aria-label={i18n.translate('xpack.searchIndices.connectionDetails.copyMessage', {
defaultMessage: 'Copy Elasticsearch URL to clipboard',
})}
/>
)}
</EuiCopy>
</EuiFlexItem>
</EuiFlexGroup>
<FormInfoField
label={i18n.translate('xpack.searchIndices.connectionDetails.endpointTitle', {
defaultMessage: 'Elasticsearch URL',
})}
value={elasticsearchUrl}
copyValue={elasticsearchUrl}
dataTestSubj="connectionDetailsEndpoint"
/>
);
};

View file

@ -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 (
<EuiPanel

View file

@ -26,6 +26,7 @@ import { useParams } from 'react-router-dom';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import { SectionLoading } from '@kbn/es-ui-shared-plugin/public';
import { ApiKeyForm } from '@kbn/search-api-keys-components';
import { useIndex } from '../../hooks/api/use_index';
import { useKibana } from '../../hooks/use_kibana';
import { ConnectionDetails } from '../connection_details/connection_details';
@ -228,11 +229,13 @@ export const SearchIndexDetailsPage = () => {
<EuiPageTemplate.Section grow={false}>
<EuiFlexGroup direction="column">
<EuiFlexItem>
<EuiFlexGroup>
<EuiFlexItem>
<EuiFlexGroup css={{ overflow: 'auto' }}>
<EuiFlexItem css={{ flexShrink: 0 }}>
<ConnectionDetails />
</EuiFlexItem>
<EuiFlexItem>{/* TODO: API KEY */}</EuiFlexItem>
<EuiFlexItem css={{ flexShrink: 0 }}>
<ApiKeyForm />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>

View file

@ -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 (
<EuiFlexGroup direction="column" data-test-subj="createIndexCodeView">
<EuiFlexItem grow={true}>
<EuiPanel paddingSize="m" hasShadow={false} hasBorder={true} color="plain">
<EuiFlexGroup direction="column" gutterSize="s">
<EuiFlexItem>
<EuiText>
<h5>
{i18n.translate('xpack.searchIndices.startPage.codeView.apiKeyTitle', {
defaultMessage: 'Copy your API key',
})}
</h5>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText color="subdued">
<p>
{i18n.translate('xpack.searchIndices.startPage.codeView.apiKeyDescription', {
defaultMessage:
'Make sure you keep it somewhere safe. You wont be able to retrieve it later.',
})}
</p>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<ApiKeyForm hasTitle={false} />
</EuiPanel>
</EuiFlexItem>
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem css={{ maxWidth: '300px' }}>
<LanguageSelector
@ -94,6 +125,7 @@ export const CreateIndexCodeView = ({
/>
)}
<CodeSample
id="createIndex"
title={i18n.translate('xpack.searchIndices.startPage.codeView.createIndex.title', {
defaultMessage: 'Connect and create an index',
})}

View file

@ -8,10 +8,12 @@
import type { IRouter } from '@kbn/core/server';
import type { Logger } from '@kbn/logging';
import { registerSearchApiKeysRoutes } from '@kbn/search-api-keys-server';
import { registerIndicesRoutes } from './indices';
import { registerStatusRoutes } from './status';
export function defineRoutes(router: IRouter, logger: Logger) {
registerIndicesRoutes(router, logger);
registerStatusRoutes(router, logger);
registerSearchApiKeysRoutes(router, logger);
}

View file

@ -34,6 +34,9 @@
"@kbn/cloud-plugin",
"@kbn/search-index-documents",
"@kbn/es-types",
"@kbn/search-api-keys-server",
"@kbn/search-api-keys-components",
"@kbn/search-shared-ui"
],
"exclude": [
"target/**/*",

View file

@ -172,44 +172,6 @@ export function defineRoutes({
})
);
router.post(
{
path: APIRoutes.POST_API_KEY,
validate: {
body: schema.object({
name: schema.string(),
expiresInDays: schema.number(),
indices: schema.arrayOf(schema.string()),
}),
},
},
errorHandler(logger)(async (context, request, response) => {
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(

View file

@ -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,
};

View file

@ -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');
},
};
}

View file

@ -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);
});
},
};
}

View file

@ -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
);
},
};
}

View file

@ -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();
});
});
});
});

View file

@ -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();

View file

@ -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 ""