mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
Added ability to create API keys (#92610)
* Added ability to create API keys * Remove hard coded colours * Added unit tests * Fix linting errors * Display full base64 encoded API key * Fix linting errors * Fix more linting error and unit tests * Added suggestions from code review * fix unit tests * move code editor field into separate component * fixed tests * fixed test * Fixed functional tests * replaced theme hook with eui import * Revert to manual theme detection * added storybook * Additional unit and functional tests * Added suggestions from code review * Remove unused translations * Updated docs and added detailed error description * Removed unused messages * Updated unit test Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Larry Gregory <larry.gregory@elastic.co>
This commit is contained in:
parent
3a7155eaa1
commit
69f013e2fb
45 changed files with 1866 additions and 947 deletions
Binary file not shown.
Before Width: | Height: | Size: 126 KiB |
BIN
docs/user/security/api-keys/images/api-keys.png
Executable file → Normal file
BIN
docs/user/security/api-keys/images/api-keys.png
Executable file → Normal file
Binary file not shown.
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 155 KiB |
BIN
docs/user/security/api-keys/images/create-api-key.png
Normal file
BIN
docs/user/security/api-keys/images/create-api-key.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 369 KiB |
|
@ -4,7 +4,7 @@
|
|||
|
||||
|
||||
API keys enable you to create secondary credentials so that you can send
|
||||
requests on behalf of the user. Secondary credentials have
|
||||
requests on behalf of a user. Secondary credentials have
|
||||
the same or lower access rights.
|
||||
|
||||
For example, if you extract data from an {es} cluster on a daily
|
||||
|
@ -14,8 +14,7 @@ and then put the API credentials into a cron job.
|
|||
Or, you might create API keys to automate ingestion of new data from
|
||||
remote sources, without a live user interaction.
|
||||
|
||||
You can create API keys from the {kib} Console. To view and invalidate
|
||||
API keys, open the main menu, then click *Stack Management > API Keys*.
|
||||
To manage API keys, open the main menu, then click *Stack Management > API Keys*.
|
||||
|
||||
[role="screenshot"]
|
||||
image:user/security/api-keys/images/api-keys.png["API Keys UI"]
|
||||
|
@ -46,37 +45,15 @@ cluster privileges to use API keys in {kib}. To manage roles, open the main menu
|
|||
[float]
|
||||
[[create-api-key]]
|
||||
=== Create an API key
|
||||
You can {ref}/security-api-create-api-key.html[create an API key] from
|
||||
the {kib} Console. This example shows how to create an API key
|
||||
to authenticate to a <<api, Kibana API>>.
|
||||
|
||||
[source,js]
|
||||
POST /_security/api_key
|
||||
{
|
||||
"name": "kibana_api_key"
|
||||
}
|
||||
To create an API key, open the main menu, then click *Stack Management > API Keys > Create API key*.
|
||||
|
||||
This creates an API key with the
|
||||
name `kibana_api_key`. API key
|
||||
names must be globally unique.
|
||||
An expiration date is optional and follows
|
||||
{ref}/common-options.html#time-units[{es} time unit format].
|
||||
When an expiration is not provided, the API key does not expire.
|
||||
[role="screenshot"]
|
||||
image:user/security/api-keys/images/create-api-key.png["Create API Key UI"]
|
||||
|
||||
The response should look something like this:
|
||||
Once created, you can copy the API key (Base64 encoded) and use it to send requests to {es} on your behalf. For example:
|
||||
|
||||
[source,js]
|
||||
{
|
||||
"id" : "XFcbCnIBnbwqt2o79G4q",
|
||||
"name" : "kibana_api_key",
|
||||
"api_key" : "FD6P5UA4QCWlZZQhYF3YGw"
|
||||
}
|
||||
|
||||
Now, you can use the API key to request {kib} roles. You'll need to send a request with a
|
||||
`Authorization` header with a value having the prefix `ApiKey` followed by the credentials,
|
||||
where credentials is the base64 encoding of `id` and `api_key` joined by a colon. For example:
|
||||
|
||||
[source,js]
|
||||
[source,bash]
|
||||
curl --location --request GET 'http://localhost:5601/api/security/role' \
|
||||
--header 'Content-Type: application/json;charset=UTF-8' \
|
||||
--header 'kbn-xsrf: true' \
|
||||
|
@ -84,20 +61,16 @@ curl --location --request GET 'http://localhost:5601/api/security/role' \
|
|||
|
||||
[float]
|
||||
[[view-api-keys]]
|
||||
=== View and invalidate API keys
|
||||
The *API Keys* feature in Kibana lists your API keys, including the name, date created,
|
||||
and expiration date. If an API key expires, its status changes from `Active` to `Expired`.
|
||||
=== View and delete API keys
|
||||
|
||||
The *API Keys* feature in Kibana lists your API keys, including the name, date created, and status. If an API key expires, its status changes from `Active` to `Expired`.
|
||||
|
||||
If you have `manage_security` or `manage_api_key` permissions,
|
||||
you can view the API keys of all users, and see which API key was
|
||||
created by which user in which realm.
|
||||
If you have only the `manage_own_api_key` permission, you see only a list of your own keys.
|
||||
|
||||
You can invalidate API keys individually or in bulk.
|
||||
Invalidated keys are deleted in batch after seven days.
|
||||
|
||||
[role="screenshot"]
|
||||
image:user/security/api-keys/images/api-key-invalidate.png["API Keys invalidate"]
|
||||
You can delete API keys individually or in bulk.
|
||||
|
||||
You cannot modify an API key. If you need additional privileges,
|
||||
you must create a new key with the desired configuration and invalidate the old key.
|
||||
|
|
|
@ -9,7 +9,21 @@ exports[`is rendered 1`] = `
|
|||
height={250}
|
||||
language="loglang"
|
||||
onChange={[Function]}
|
||||
options={Object {}}
|
||||
options={
|
||||
Object {
|
||||
"minimap": Object {
|
||||
"enabled": false,
|
||||
},
|
||||
"renderLineHighlight": "none",
|
||||
"scrollBeyondLastLine": false,
|
||||
"scrollbar": Object {
|
||||
"useShadows": false,
|
||||
},
|
||||
"wordBasedSuggestions": false,
|
||||
"wordWrap": "on",
|
||||
"wrappingIndent": "indent",
|
||||
}
|
||||
}
|
||||
overrideServices={Object {}}
|
||||
theme="euiColors"
|
||||
value="
|
||||
|
|
|
@ -78,6 +78,25 @@ storiesOf('CodeEditor', module)
|
|||
},
|
||||
}
|
||||
)
|
||||
.add(
|
||||
'transparent background',
|
||||
() => (
|
||||
<div>
|
||||
<CodeEditor
|
||||
languageId="plaintext"
|
||||
height={250}
|
||||
value="Hello!"
|
||||
onChange={action('onChange')}
|
||||
transparentBackground
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
{
|
||||
info: {
|
||||
text: 'Plaintext Monaco Editor',
|
||||
},
|
||||
}
|
||||
)
|
||||
.add(
|
||||
'custom log language',
|
||||
() => (
|
||||
|
|
|
@ -89,8 +89,8 @@ test('editor mount setup', () => {
|
|||
// Verify our mount callback will be called
|
||||
expect(editorWillMount.mock.calls.length).toBe(1);
|
||||
|
||||
// Verify our theme will be setup
|
||||
expect((monaco.editor.defineTheme as jest.Mock).mock.calls.length).toBe(1);
|
||||
// Verify that both, default and transparent theme will be setup
|
||||
expect((monaco.editor.defineTheme as jest.Mock).mock.calls.length).toBe(2);
|
||||
|
||||
// Verify our language features have been registered
|
||||
expect((monaco.languages.onLanguage as jest.Mock).mock.calls.length).toBe(1);
|
||||
|
|
|
@ -9,10 +9,14 @@
|
|||
import React from 'react';
|
||||
import ReactResizeDetector from 'react-resize-detector';
|
||||
import MonacoEditor from 'react-monaco-editor';
|
||||
|
||||
import { monaco } from '@kbn/monaco';
|
||||
|
||||
import { LIGHT_THEME, DARK_THEME } from './editor_theme';
|
||||
import {
|
||||
DARK_THEME,
|
||||
LIGHT_THEME,
|
||||
DARK_THEME_TRANSPARENT,
|
||||
LIGHT_THEME_TRANSPARENT,
|
||||
} from './editor_theme';
|
||||
|
||||
import './editor.scss';
|
||||
|
||||
|
@ -86,6 +90,11 @@ export interface Props {
|
|||
* Should the editor use the dark theme
|
||||
*/
|
||||
useDarkTheme?: boolean;
|
||||
|
||||
/**
|
||||
* Should the editor use a transparent background
|
||||
*/
|
||||
transparentBackground?: boolean;
|
||||
}
|
||||
|
||||
export class CodeEditor extends React.Component<Props, {}> {
|
||||
|
@ -132,8 +141,12 @@ export class CodeEditor extends React.Component<Props, {}> {
|
|||
}
|
||||
});
|
||||
|
||||
// Register the theme
|
||||
// Register themes
|
||||
monaco.editor.defineTheme('euiColors', this.props.useDarkTheme ? DARK_THEME : LIGHT_THEME);
|
||||
monaco.editor.defineTheme(
|
||||
'euiColorsTransparent',
|
||||
this.props.useDarkTheme ? DARK_THEME_TRANSPARENT : LIGHT_THEME_TRANSPARENT
|
||||
);
|
||||
};
|
||||
|
||||
_editorDidMount = (editor: monaco.editor.IStandaloneCodeEditor, __monaco: unknown) => {
|
||||
|
@ -152,20 +165,33 @@ export class CodeEditor extends React.Component<Props, {}> {
|
|||
const { languageId, value, onChange, width, height, options } = this.props;
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<>
|
||||
<MonacoEditor
|
||||
theme="euiColors"
|
||||
theme={this.props.transparentBackground ? 'euiColorsTransparent' : 'euiColors'}
|
||||
language={languageId}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
editorWillMount={this._editorWillMount}
|
||||
editorDidMount={this._editorDidMount}
|
||||
width={width}
|
||||
height={height}
|
||||
options={options}
|
||||
editorWillMount={this._editorWillMount}
|
||||
editorDidMount={this._editorDidMount}
|
||||
options={{
|
||||
renderLineHighlight: 'none',
|
||||
scrollBeyondLastLine: false,
|
||||
minimap: {
|
||||
enabled: false,
|
||||
},
|
||||
scrollbar: {
|
||||
useShadows: false,
|
||||
},
|
||||
wordBasedSuggestions: false,
|
||||
wordWrap: 'on',
|
||||
wrappingIndent: 'indent',
|
||||
...options,
|
||||
}}
|
||||
/>
|
||||
<ReactResizeDetector handleWidth handleHeight onResize={this._updateDimensions} />
|
||||
</React.Fragment>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -16,7 +16,8 @@ import lightTheme from '@elastic/eui/dist/eui_theme_light.json';
|
|||
|
||||
export function createTheme(
|
||||
euiTheme: typeof darkTheme | typeof lightTheme,
|
||||
selectionBackgroundColor: string
|
||||
selectionBackgroundColor: string,
|
||||
backgroundColor?: string
|
||||
): monaco.editor.IStandaloneThemeData {
|
||||
return {
|
||||
base: 'vs',
|
||||
|
@ -87,7 +88,7 @@ export function createTheme(
|
|||
],
|
||||
colors: {
|
||||
'editor.foreground': euiTheme.euiColorDarkestShade,
|
||||
'editor.background': euiTheme.euiFormBackgroundColor,
|
||||
'editor.background': backgroundColor ?? euiTheme.euiFormBackgroundColor,
|
||||
'editorLineNumber.foreground': euiTheme.euiColorDarkShade,
|
||||
'editorLineNumber.activeForeground': euiTheme.euiColorDarkShade,
|
||||
'editorIndentGuide.background': euiTheme.euiColorLightShade,
|
||||
|
@ -105,5 +106,7 @@ export function createTheme(
|
|||
|
||||
const DARK_THEME = createTheme(darkTheme, '#343551');
|
||||
const LIGHT_THEME = createTheme(lightTheme, '#E3E4ED');
|
||||
const DARK_THEME_TRANSPARENT = createTheme(darkTheme, '#343551', '#00000000');
|
||||
const LIGHT_THEME_TRANSPARENT = createTheme(lightTheme, '#E3E4ED', '#00000000');
|
||||
|
||||
export { DARK_THEME, LIGHT_THEME };
|
||||
export { DARK_THEME, LIGHT_THEME, DARK_THEME_TRANSPARENT, LIGHT_THEME_TRANSPARENT };
|
||||
|
|
|
@ -7,7 +7,14 @@
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { EuiDelayRender, EuiLoadingContent } from '@elastic/eui';
|
||||
import {
|
||||
EuiDelayRender,
|
||||
EuiErrorBoundary,
|
||||
EuiLoadingContent,
|
||||
EuiFormControlLayout,
|
||||
} from '@elastic/eui';
|
||||
import darkTheme from '@elastic/eui/dist/eui_theme_dark.json';
|
||||
import lightTheme from '@elastic/eui/dist/eui_theme_light.json';
|
||||
import { useUiSetting } from '../ui_settings';
|
||||
import type { Props } from './code_editor';
|
||||
|
||||
|
@ -19,11 +26,54 @@ const Fallback = () => (
|
|||
</EuiDelayRender>
|
||||
);
|
||||
|
||||
/**
|
||||
* Renders a Monaco code editor with EUI color theme.
|
||||
*
|
||||
* @see CodeEditorField to render a code editor in the same style as other EUI form fields.
|
||||
*/
|
||||
export const CodeEditor: React.FunctionComponent<Props> = (props) => {
|
||||
const darkMode = useUiSetting<boolean>('theme:darkMode');
|
||||
return (
|
||||
<React.Suspense fallback={<Fallback />}>
|
||||
<LazyBaseEditor {...props} useDarkTheme={darkMode} />
|
||||
</React.Suspense>
|
||||
<EuiErrorBoundary>
|
||||
<React.Suspense fallback={<Fallback />}>
|
||||
<LazyBaseEditor {...props} useDarkTheme={darkMode} />
|
||||
</React.Suspense>
|
||||
</EuiErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a Monaco code editor in the same style as other EUI form fields.
|
||||
*/
|
||||
export const CodeEditorField: React.FunctionComponent<Props> = (props) => {
|
||||
const { width, height, options } = props;
|
||||
const darkMode = useUiSetting<boolean>('theme:darkMode');
|
||||
const theme = darkMode ? darkTheme : lightTheme;
|
||||
const style = {
|
||||
width,
|
||||
height,
|
||||
backgroundColor: options?.readOnly
|
||||
? theme.euiFormBackgroundReadOnlyColor
|
||||
: theme.euiFormBackgroundColor,
|
||||
};
|
||||
|
||||
return (
|
||||
<EuiErrorBoundary>
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<EuiFormControlLayout
|
||||
append={<div hidden />}
|
||||
style={{ ...style, padding: theme.paddingSizes.m }}
|
||||
readOnly={options?.readOnly}
|
||||
>
|
||||
<Fallback />
|
||||
</EuiFormControlLayout>
|
||||
}
|
||||
>
|
||||
<EuiFormControlLayout append={<div hidden />} style={style} readOnly={options?.readOnly}>
|
||||
<LazyBaseEditor {...props} useDarkTheme={darkMode} transparentBackground />
|
||||
</EuiFormControlLayout>
|
||||
</React.Suspense>
|
||||
</EuiErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -5,6 +5,8 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { Role } from './role';
|
||||
|
||||
export interface ApiKey {
|
||||
id: string;
|
||||
name: string;
|
||||
|
@ -19,3 +21,5 @@ export interface ApiKeyToInvalidate {
|
|||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export type ApiKeyRoleDescriptors = Record<string, Role['elasticsearch']>;
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
export { ApiKey, ApiKeyToInvalidate } from './api_key';
|
||||
export { ApiKey, ApiKeyToInvalidate, ApiKeyRoleDescriptors } from './api_key';
|
||||
export { User, EditUser, getUserDisplayName } from './user';
|
||||
export { AuthenticatedUser, canUserChangePassword } from './authenticated_user';
|
||||
export { AuthenticationProvider, shouldProviderUseLoginForm } from './authentication_provider';
|
||||
|
|
|
@ -9,6 +9,8 @@ import type { EuiBreadcrumb } from '@elastic/eui';
|
|||
import type { FunctionComponent } from 'react';
|
||||
import React, { createContext, useContext, useEffect, useRef } from 'react';
|
||||
|
||||
import type { ChromeStart } from 'src/core/public';
|
||||
|
||||
import { useKibana } from '../../../../../src/plugins/kibana_react/public';
|
||||
|
||||
interface BreadcrumbsContext {
|
||||
|
@ -81,8 +83,8 @@ export const BreadcrumbsProvider: FunctionComponent<BreadcrumbsProviderProps> =
|
|||
if (onChange) {
|
||||
onChange(breadcrumbs);
|
||||
} else if (services.chrome) {
|
||||
services.chrome.setBreadcrumbs(breadcrumbs);
|
||||
services.chrome.docTitle.change(getDocTitle(breadcrumbs));
|
||||
const setBreadcrumbs = createBreadcrumbsChangeHandler(services.chrome);
|
||||
setBreadcrumbs(breadcrumbs);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -138,3 +140,17 @@ export function getDocTitle(breadcrumbs: BreadcrumbProps[], maxBreadcrumbs = 2)
|
|||
.reverse()
|
||||
.map(({ text }) => text);
|
||||
}
|
||||
|
||||
export function createBreadcrumbsChangeHandler(
|
||||
chrome: Pick<ChromeStart, 'docTitle' | 'setBreadcrumbs'>,
|
||||
setBreadcrumbs = chrome.setBreadcrumbs
|
||||
) {
|
||||
return (breadcrumbs: BreadcrumbProps[]) => {
|
||||
setBreadcrumbs(breadcrumbs);
|
||||
if (breadcrumbs.length === 0) {
|
||||
chrome.docTitle.reset();
|
||||
} else {
|
||||
chrome.docTitle.change(getDocTitle(breadcrumbs));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,84 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { EuiButtonProps, EuiModalProps } from '@elastic/eui';
|
||||
import {
|
||||
EuiButton,
|
||||
EuiButtonEmpty,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiModal,
|
||||
EuiModalBody,
|
||||
EuiModalFooter,
|
||||
EuiModalHeader,
|
||||
EuiModalHeaderTitle,
|
||||
} from '@elastic/eui';
|
||||
import type { FunctionComponent } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
|
||||
export interface ConfirmModalProps extends Omit<EuiModalProps, 'onClose' | 'initialFocus'> {
|
||||
confirmButtonText: string;
|
||||
confirmButtonColor?: EuiButtonProps['color'];
|
||||
isLoading?: EuiButtonProps['isLoading'];
|
||||
isDisabled?: EuiButtonProps['isDisabled'];
|
||||
onCancel(): void;
|
||||
onConfirm(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that renders a confirmation modal similar to `EuiConfirmModal`, except that
|
||||
* it adds `isLoading` prop, which renders a loading spinner and disables action buttons.
|
||||
*/
|
||||
export const ConfirmModal: FunctionComponent<ConfirmModalProps> = ({
|
||||
children,
|
||||
confirmButtonColor: buttonColor,
|
||||
confirmButtonText,
|
||||
isLoading,
|
||||
isDisabled,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
title,
|
||||
...rest
|
||||
}) => (
|
||||
<EuiModal role="dialog" title={title} onClose={onCancel} {...rest}>
|
||||
<EuiModalHeader>
|
||||
<EuiModalHeaderTitle>{title}</EuiModalHeaderTitle>
|
||||
</EuiModalHeader>
|
||||
<EuiModalBody data-test-subj="confirmModalBodyText">{children}</EuiModalBody>
|
||||
<EuiModalFooter>
|
||||
<EuiFlexGroup justifyContent="flexEnd">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty
|
||||
data-test-subj="confirmModalCancelButton"
|
||||
flush="right"
|
||||
isDisabled={isLoading}
|
||||
onClick={onCancel}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.security.confirmModal.cancelButton"
|
||||
defaultMessage="Cancel"
|
||||
/>
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
data-test-subj="confirmModalConfirmButton"
|
||||
color={buttonColor}
|
||||
fill
|
||||
isLoading={isLoading}
|
||||
isDisabled={isDisabled}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmButtonText}
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiModalFooter>
|
||||
</EuiModal>
|
||||
);
|
140
x-pack/plugins/security/public/components/token_field.tsx
Normal file
140
x-pack/plugins/security/public/components/token_field.tsx
Normal file
|
@ -0,0 +1,140 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { EuiFieldTextProps } from '@elastic/eui';
|
||||
import {
|
||||
EuiButtonEmpty,
|
||||
EuiButtonIcon,
|
||||
EuiContextMenuItem,
|
||||
EuiContextMenuPanel,
|
||||
EuiCopy,
|
||||
EuiFormControlLayout,
|
||||
EuiHorizontalRule,
|
||||
EuiPopover,
|
||||
EuiSpacer,
|
||||
EuiText,
|
||||
} from '@elastic/eui';
|
||||
import type { FunctionComponent, ReactElement } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { euiThemeVars } from '@kbn/ui-shared-deps/theme';
|
||||
|
||||
export interface TokenFieldProps extends Omit<EuiFieldTextProps, 'append'> {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export const TokenField: FunctionComponent<TokenFieldProps> = (props) => {
|
||||
return (
|
||||
<EuiFormControlLayout
|
||||
{...props}
|
||||
append={
|
||||
<EuiCopy textToCopy={props.value}>
|
||||
{(copyText) => (
|
||||
<EuiButtonIcon
|
||||
aria-label={i18n.translate('xpack.security.copyTokenField.copyButton', {
|
||||
defaultMessage: 'Copy to clipboard',
|
||||
})}
|
||||
iconType="copyClipboard"
|
||||
color="success"
|
||||
style={{ backgroundColor: 'transparent' }}
|
||||
onClick={copyText}
|
||||
/>
|
||||
)}
|
||||
</EuiCopy>
|
||||
}
|
||||
style={{ backgroundColor: 'transparent' }}
|
||||
readOnly
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
aria-label={i18n.translate('xpack.security.copyTokenField.tokenLabel', {
|
||||
defaultMessage: 'Token',
|
||||
})}
|
||||
className="euiFieldText euiFieldText--inGroup"
|
||||
value={props.value}
|
||||
style={{ fontFamily: euiThemeVars.euiCodeFontFamily, fontSize: euiThemeVars.euiFontSizeXS }}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
readOnly
|
||||
/>
|
||||
</EuiFormControlLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export interface SelectableTokenFieldOption {
|
||||
key: string;
|
||||
value: string;
|
||||
icon?: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface SelectableTokenFieldProps extends Omit<EuiFieldTextProps, 'value' | 'prepend'> {
|
||||
options: SelectableTokenFieldOption[];
|
||||
}
|
||||
|
||||
export const SelectableTokenField: FunctionComponent<SelectableTokenFieldProps> = (props) => {
|
||||
const { options, ...rest } = props;
|
||||
const [isPopoverOpen, setIsPopoverOpen] = React.useState(false);
|
||||
const [selectedOption, setSelectedOption] = React.useState<SelectableTokenFieldOption>(
|
||||
options[0]
|
||||
);
|
||||
const selectedIndex = options.findIndex((c) => c.key === selectedOption.key);
|
||||
const closePopover = () => setIsPopoverOpen(false);
|
||||
|
||||
return (
|
||||
<TokenField
|
||||
{...rest}
|
||||
prepend={
|
||||
<EuiPopover
|
||||
button={
|
||||
<EuiButtonEmpty
|
||||
size="xs"
|
||||
iconType="arrowDown"
|
||||
iconSide="right"
|
||||
color="success"
|
||||
onClick={() => setIsPopoverOpen(!isPopoverOpen)}
|
||||
>
|
||||
{selectedOption.label}
|
||||
</EuiButtonEmpty>
|
||||
}
|
||||
isOpen={isPopoverOpen}
|
||||
panelPaddingSize="none"
|
||||
closePopover={closePopover}
|
||||
>
|
||||
<EuiContextMenuPanel
|
||||
initialFocusedItemIndex={selectedIndex * 2}
|
||||
items={options.reduce<ReactElement[]>((items, option, i) => {
|
||||
items.push(
|
||||
<EuiContextMenuItem
|
||||
key={option.key}
|
||||
icon={option.icon}
|
||||
layoutAlign="top"
|
||||
onClick={() => {
|
||||
closePopover();
|
||||
setSelectedOption(option);
|
||||
}}
|
||||
>
|
||||
<strong>{option.label}</strong>
|
||||
<EuiSpacer size="xs" />
|
||||
<EuiText size="s" color="subdued">
|
||||
<p>{option.description}</p>
|
||||
</EuiText>
|
||||
</EuiContextMenuItem>
|
||||
);
|
||||
if (i < options.length - 1) {
|
||||
items.push(<EuiHorizontalRule key={`${option.key}-seperator`} margin="none" />);
|
||||
}
|
||||
return items;
|
||||
}, [])}
|
||||
/>
|
||||
</EuiPopover>
|
||||
}
|
||||
value={selectedOption.value}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { DependencyList } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* Creates a ref for an HTML element, which will be focussed on mount.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const firstInput = useInitialFocus();
|
||||
*
|
||||
* <EuiFieldText inputRef={firstInput} />
|
||||
* ```
|
||||
*
|
||||
* Pass in a dependency list to focus conditionally rendered components:
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const firstInput = useInitialFocus([showField]);
|
||||
*
|
||||
* {showField ? <input ref={firstInput} /> : undefined}
|
||||
* ```
|
||||
*/
|
||||
export function useInitialFocus<T extends HTMLElement>(deps: DependencyList = []) {
|
||||
const inputRef = useRef<T>(null);
|
||||
useEffect(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, deps); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
return inputRef;
|
||||
}
|
|
@ -10,5 +10,6 @@ export const apiKeysAPIClientMock = {
|
|||
checkPrivileges: jest.fn(),
|
||||
getApiKeys: jest.fn(),
|
||||
invalidateApiKeys: jest.fn(),
|
||||
createApiKey: jest.fn(),
|
||||
}),
|
||||
};
|
||||
|
|
|
@ -84,4 +84,20 @@ describe('APIKeysAPIClient', () => {
|
|||
body: JSON.stringify({ apiKeys: mockAPIKeys, isAdmin: true }),
|
||||
});
|
||||
});
|
||||
|
||||
it('createApiKey() queries correct endpoint', async () => {
|
||||
const httpMock = httpServiceMock.createStartContract();
|
||||
|
||||
const mockResponse = Symbol('mockResponse');
|
||||
httpMock.post.mockResolvedValue(mockResponse);
|
||||
|
||||
const apiClient = new APIKeysAPIClient(httpMock);
|
||||
const mockAPIKeys = { name: 'name', expiration: '7d' };
|
||||
|
||||
await expect(apiClient.createApiKey(mockAPIKeys)).resolves.toBe(mockResponse);
|
||||
expect(httpMock.post).toHaveBeenCalledTimes(1);
|
||||
expect(httpMock.post).toHaveBeenCalledWith('/internal/security/api_key', {
|
||||
body: JSON.stringify(mockAPIKeys),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -7,23 +7,36 @@
|
|||
|
||||
import type { HttpStart } from 'src/core/public';
|
||||
|
||||
import type { ApiKey, ApiKeyToInvalidate } from '../../../common/model';
|
||||
import type { ApiKey, ApiKeyRoleDescriptors, ApiKeyToInvalidate } from '../../../common/model';
|
||||
|
||||
interface CheckPrivilegesResponse {
|
||||
export interface CheckPrivilegesResponse {
|
||||
areApiKeysEnabled: boolean;
|
||||
isAdmin: boolean;
|
||||
canManage: boolean;
|
||||
}
|
||||
|
||||
interface InvalidateApiKeysResponse {
|
||||
export interface InvalidateApiKeysResponse {
|
||||
itemsInvalidated: ApiKeyToInvalidate[];
|
||||
errors: any[];
|
||||
}
|
||||
|
||||
interface GetApiKeysResponse {
|
||||
export interface GetApiKeysResponse {
|
||||
apiKeys: ApiKey[];
|
||||
}
|
||||
|
||||
export interface CreateApiKeyRequest {
|
||||
name: string;
|
||||
expiration?: string;
|
||||
role_descriptors?: ApiKeyRoleDescriptors;
|
||||
}
|
||||
|
||||
export interface CreateApiKeyResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
expiration: number;
|
||||
api_key: string;
|
||||
}
|
||||
|
||||
const apiKeysUrl = '/internal/security/api_key';
|
||||
|
||||
export class APIKeysAPIClient {
|
||||
|
@ -42,4 +55,10 @@ export class APIKeysAPIClient {
|
|||
body: JSON.stringify({ apiKeys, isAdmin }),
|
||||
});
|
||||
}
|
||||
|
||||
public async createApiKey(apiKey: CreateApiKeyRequest) {
|
||||
return await this.http.post<CreateApiKeyResponse>(apiKeysUrl, {
|
||||
body: JSON.stringify(apiKey),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,243 +0,0 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`APIKeysGridPage renders a callout when API keys are not enabled 1`] = `
|
||||
<EuiCallOut
|
||||
color="danger"
|
||||
iconType="alert"
|
||||
title={
|
||||
<FormattedMessage
|
||||
defaultMessage="API keys not enabled in Elasticsearch"
|
||||
id="xpack.security.management.apiKeys.table.apiKeysDisabledErrorTitle"
|
||||
values={Object {}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="euiCallOut euiCallOut--danger"
|
||||
>
|
||||
<div
|
||||
className="euiCallOutHeader"
|
||||
>
|
||||
<EuiIcon
|
||||
aria-hidden="true"
|
||||
className="euiCallOutHeader__icon"
|
||||
size="m"
|
||||
type="alert"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="euiCallOutHeader__icon"
|
||||
data-euiicon-type="alert"
|
||||
size="m"
|
||||
/>
|
||||
</EuiIcon>
|
||||
<span
|
||||
className="euiCallOutHeader__title"
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="API keys not enabled in Elasticsearch"
|
||||
id="xpack.security.management.apiKeys.table.apiKeysDisabledErrorTitle"
|
||||
values={Object {}}
|
||||
>
|
||||
API keys not enabled in Elasticsearch
|
||||
</FormattedMessage>
|
||||
</span>
|
||||
</div>
|
||||
<EuiText
|
||||
size="s"
|
||||
>
|
||||
<div
|
||||
className="euiText euiText--small"
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="Contact your system administrator and refer to the {link} to enable API keys."
|
||||
id="xpack.security.management.apiKeys.table.apiKeysDisabledErrorDescription"
|
||||
values={
|
||||
Object {
|
||||
"link": <EuiLink
|
||||
href="https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/security-settings.html#api-key-service-settings"
|
||||
target="_blank"
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="docs"
|
||||
id="xpack.security.management.apiKeys.table.apiKeysDisabledErrorLinkText"
|
||||
values={Object {}}
|
||||
/>
|
||||
</EuiLink>,
|
||||
}
|
||||
}
|
||||
>
|
||||
Contact your system administrator and refer to the
|
||||
<EuiLink
|
||||
href="https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/security-settings.html#api-key-service-settings"
|
||||
target="_blank"
|
||||
>
|
||||
<a
|
||||
className="euiLink euiLink--primary"
|
||||
href="https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/security-settings.html#api-key-service-settings"
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="docs"
|
||||
id="xpack.security.management.apiKeys.table.apiKeysDisabledErrorLinkText"
|
||||
values={Object {}}
|
||||
>
|
||||
docs
|
||||
</FormattedMessage>
|
||||
<EuiIcon
|
||||
aria-label="External link"
|
||||
className="euiLink__externalIcon"
|
||||
size="s"
|
||||
type="popout"
|
||||
>
|
||||
<span
|
||||
aria-label="External link"
|
||||
className="euiLink__externalIcon"
|
||||
data-euiicon-type="popout"
|
||||
size="s"
|
||||
/>
|
||||
</EuiIcon>
|
||||
<EuiScreenReaderOnly>
|
||||
<span
|
||||
className="euiScreenReaderOnly"
|
||||
>
|
||||
<EuiI18n
|
||||
default="(opens in a new tab or window)"
|
||||
token="euiLink.newTarget.screenReaderOnlyText"
|
||||
>
|
||||
(opens in a new tab or window)
|
||||
</EuiI18n>
|
||||
</span>
|
||||
</EuiScreenReaderOnly>
|
||||
</a>
|
||||
</EuiLink>
|
||||
to enable API keys.
|
||||
</FormattedMessage>
|
||||
</div>
|
||||
</EuiText>
|
||||
</div>
|
||||
</EuiCallOut>
|
||||
`;
|
||||
|
||||
exports[`APIKeysGridPage renders permission denied if user does not have required permissions 1`] = `
|
||||
<PermissionDenied>
|
||||
<EuiFlexGroup
|
||||
gutterSize="none"
|
||||
>
|
||||
<div
|
||||
className="euiFlexGroup euiFlexGroup--directionRow euiFlexGroup--responsive"
|
||||
>
|
||||
<EuiPageContent
|
||||
horizontalPosition="center"
|
||||
>
|
||||
<EuiPanel
|
||||
className="euiPageContent euiPageContent--horizontalCenter"
|
||||
paddingSize="l"
|
||||
>
|
||||
<div
|
||||
className="euiPanel euiPanel--paddingLarge euiPanel--borderRadiusMedium euiPanel--plain euiPanel--hasShadow euiPageContent euiPageContent--horizontalCenter"
|
||||
>
|
||||
<EuiEmptyPrompt
|
||||
body={
|
||||
<p
|
||||
data-test-subj="permissionDeniedMessage"
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="Contact your system administrator."
|
||||
id="xpack.security.management.apiKeys.noPermissionToManageRolesDescription"
|
||||
values={Object {}}
|
||||
/>
|
||||
</p>
|
||||
}
|
||||
iconType="securityApp"
|
||||
title={
|
||||
<h2
|
||||
data-test-subj="apiKeysPermissionDeniedMessage"
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="You need permission to manage API keys"
|
||||
id="xpack.security.management.apiKeys.deniedPermissionTitle"
|
||||
values={Object {}}
|
||||
/>
|
||||
</h2>
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="euiEmptyPrompt"
|
||||
>
|
||||
<EuiIcon
|
||||
color="subdued"
|
||||
size="xxl"
|
||||
type="securityApp"
|
||||
>
|
||||
<span
|
||||
color="subdued"
|
||||
data-euiicon-type="securityApp"
|
||||
size="xxl"
|
||||
/>
|
||||
</EuiIcon>
|
||||
<EuiSpacer
|
||||
size="s"
|
||||
>
|
||||
<div
|
||||
className="euiSpacer euiSpacer--s"
|
||||
/>
|
||||
</EuiSpacer>
|
||||
<EuiTextColor
|
||||
color="subdued"
|
||||
>
|
||||
<span
|
||||
className="euiTextColor euiTextColor--subdued"
|
||||
>
|
||||
<EuiTitle
|
||||
size="m"
|
||||
>
|
||||
<h2
|
||||
className="euiTitle euiTitle--medium"
|
||||
data-test-subj="apiKeysPermissionDeniedMessage"
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="You need permission to manage API keys"
|
||||
id="xpack.security.management.apiKeys.deniedPermissionTitle"
|
||||
values={Object {}}
|
||||
>
|
||||
You need permission to manage API keys
|
||||
</FormattedMessage>
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
<EuiSpacer
|
||||
size="m"
|
||||
>
|
||||
<div
|
||||
className="euiSpacer euiSpacer--m"
|
||||
/>
|
||||
</EuiSpacer>
|
||||
<EuiText>
|
||||
<div
|
||||
className="euiText euiText--medium"
|
||||
>
|
||||
<p
|
||||
data-test-subj="permissionDeniedMessage"
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="Contact your system administrator."
|
||||
id="xpack.security.management.apiKeys.noPermissionToManageRolesDescription"
|
||||
values={Object {}}
|
||||
>
|
||||
Contact your system administrator.
|
||||
</FormattedMessage>
|
||||
</p>
|
||||
</div>
|
||||
</EuiText>
|
||||
</span>
|
||||
</EuiTextColor>
|
||||
</div>
|
||||
</EuiEmptyPrompt>
|
||||
</div>
|
||||
</EuiPanel>
|
||||
</EuiPageContent>
|
||||
</div>
|
||||
</EuiFlexGroup>
|
||||
</PermissionDenied>
|
||||
`;
|
|
@ -0,0 +1,148 @@
|
|||
/*
|
||||
* 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 { EuiAccordion, EuiEmptyPrompt, EuiErrorBoundary, EuiSpacer, EuiText } from '@elastic/eui';
|
||||
import type { FunctionComponent } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
|
||||
import { DocLink } from '../../../components/doc_link';
|
||||
import { useHtmlId } from '../../../components/use_html_id';
|
||||
|
||||
export interface ApiKeysEmptyPromptProps {
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export const ApiKeysEmptyPrompt: FunctionComponent<ApiKeysEmptyPromptProps> = ({
|
||||
error,
|
||||
children,
|
||||
}) => {
|
||||
const accordionId = useHtmlId('apiKeysEmptyPrompt', 'accordion');
|
||||
|
||||
if (error) {
|
||||
if (doesErrorIndicateAPIKeysAreDisabled(error)) {
|
||||
return (
|
||||
<EuiEmptyPrompt
|
||||
iconType="alert"
|
||||
body={
|
||||
<>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.apiKeysEmptyPrompt.disabledErrorMessage"
|
||||
defaultMessage="API keys are disabled."
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
<DocLink app="elasticsearch" doc="security-settings.html#api-key-service-settings">
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.apiKeysEmptyPrompt.docsLinkText"
|
||||
defaultMessage="Learn how to enable API keys."
|
||||
/>
|
||||
</DocLink>
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (doesErrorIndicateUserHasNoPermissionsToManageAPIKeys(error)) {
|
||||
return (
|
||||
<EuiEmptyPrompt
|
||||
iconType="lock"
|
||||
body={
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.apiKeysEmptyPrompt.forbiddenErrorMessage"
|
||||
defaultMessage="Not authorized to manage API keys."
|
||||
/>
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const ThrowError = () => {
|
||||
throw error;
|
||||
};
|
||||
|
||||
return (
|
||||
<EuiEmptyPrompt
|
||||
iconType="alert"
|
||||
body={
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.apiKeysEmptyPrompt.errorMessage"
|
||||
defaultMessage="Could not load API keys."
|
||||
/>
|
||||
</p>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
{children}
|
||||
|
||||
<EuiSpacer size="xl" />
|
||||
<EuiAccordion
|
||||
id={accordionId}
|
||||
buttonClassName="euiButtonEmpty euiButtonEmpty--primary euiButtonEmpty--xSmall"
|
||||
buttonContent={
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.apiKeysEmptyPrompt.technicalDetailsButton"
|
||||
defaultMessage="Technical details"
|
||||
/>
|
||||
}
|
||||
buttonProps={{
|
||||
style: { display: 'flex', justifyContent: 'center' },
|
||||
}}
|
||||
arrowDisplay="right"
|
||||
paddingSize="m"
|
||||
>
|
||||
<EuiText textAlign="left">
|
||||
<EuiErrorBoundary>
|
||||
<ThrowError />
|
||||
</EuiErrorBoundary>
|
||||
</EuiText>
|
||||
</EuiAccordion>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EuiEmptyPrompt
|
||||
iconType="gear"
|
||||
title={
|
||||
<h1>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.apiKeysEmptyPrompt.emptyTitle"
|
||||
defaultMessage="Create your first API key"
|
||||
/>
|
||||
</h1>
|
||||
}
|
||||
body={
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.apiKeysEmptyPrompt.emptyMessage"
|
||||
defaultMessage="Allow applications to access Elastic on your behalf."
|
||||
/>
|
||||
</p>
|
||||
}
|
||||
actions={children}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
function doesErrorIndicateAPIKeysAreDisabled(error: Record<string, any>) {
|
||||
const message = error.body?.message || '';
|
||||
return message.indexOf('disabled.feature="api_keys"') !== -1;
|
||||
}
|
||||
|
||||
function doesErrorIndicateUserHasNoPermissionsToManageAPIKeys(error: Record<string, any>) {
|
||||
return error.body?.statusCode === 403;
|
||||
}
|
|
@ -5,182 +5,292 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { EuiCallOut } from '@elastic/eui';
|
||||
import type { ReactWrapper } from 'enzyme';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
waitFor,
|
||||
waitForElementToBeRemoved,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import React from 'react';
|
||||
|
||||
import { mountWithIntl } from '@kbn/test/jest';
|
||||
import type { PublicMethodsOf } from '@kbn/utility-types';
|
||||
import { coreMock } from 'src/core/public/mocks';
|
||||
import { KibanaContextProvider } from 'src/plugins/kibana_react/public';
|
||||
|
||||
import type { APIKeysAPIClient } from '../api_keys_api_client';
|
||||
import { coreMock } from '../../../../../../../src/core/public/mocks';
|
||||
import { mockAuthenticatedUser } from '../../../../common/model/authenticated_user.mock';
|
||||
import { securityMock } from '../../../mocks';
|
||||
import { Providers } from '../api_keys_management_app';
|
||||
import { apiKeysAPIClientMock } from '../index.mock';
|
||||
import { APIKeysGridPage } from './api_keys_grid_page';
|
||||
import { NotEnabled } from './not_enabled';
|
||||
import { PermissionDenied } from './permission_denied';
|
||||
|
||||
const mock500 = () => ({ body: { error: 'Internal Server Error', message: '', statusCode: 500 } });
|
||||
jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({
|
||||
htmlIdGenerator: () => () => `id-${Math.random()}`,
|
||||
}));
|
||||
|
||||
const waitForRender = async (
|
||||
wrapper: ReactWrapper<any>,
|
||||
condition: (wrapper: ReactWrapper<any>) => boolean
|
||||
) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const interval = setInterval(async () => {
|
||||
await Promise.resolve();
|
||||
wrapper.update();
|
||||
if (condition(wrapper)) {
|
||||
resolve();
|
||||
}
|
||||
}, 10);
|
||||
jest.setTimeout(15000);
|
||||
|
||||
setTimeout(() => {
|
||||
clearInterval(interval);
|
||||
reject(new Error('waitForRender timeout after 2000ms'));
|
||||
}, 2000);
|
||||
});
|
||||
};
|
||||
const coreStart = coreMock.createStart();
|
||||
|
||||
const apiClientMock = apiKeysAPIClientMock.create();
|
||||
apiClientMock.checkPrivileges.mockResolvedValue({
|
||||
areApiKeysEnabled: true,
|
||||
canManage: true,
|
||||
isAdmin: true,
|
||||
});
|
||||
apiClientMock.getApiKeys.mockResolvedValue({
|
||||
apiKeys: [
|
||||
{
|
||||
creation: 1571322182082,
|
||||
expiration: 1571408582082,
|
||||
id: '0QQZ2m0BO2XZwgJFuWTT',
|
||||
invalidated: false,
|
||||
name: 'first-api-key',
|
||||
realm: 'reserved',
|
||||
username: 'elastic',
|
||||
},
|
||||
{
|
||||
creation: 1571322182082,
|
||||
expiration: 1571408582082,
|
||||
id: 'BO2XZwgJFuWTT0QQZ2m0',
|
||||
invalidated: false,
|
||||
name: 'second-api-key',
|
||||
realm: 'reserved',
|
||||
username: 'elastic',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const authc = securityMock.createSetup().authc;
|
||||
authc.getCurrentUser.mockResolvedValue(
|
||||
mockAuthenticatedUser({
|
||||
username: 'jdoe',
|
||||
full_name: '',
|
||||
email: '',
|
||||
enabled: true,
|
||||
roles: ['superuser'],
|
||||
})
|
||||
);
|
||||
|
||||
describe('APIKeysGridPage', () => {
|
||||
let apiClientMock: jest.Mocked<PublicMethodsOf<APIKeysAPIClient>>;
|
||||
beforeEach(() => {
|
||||
apiClientMock = apiKeysAPIClientMock.create();
|
||||
apiClientMock.checkPrivileges.mockResolvedValue({
|
||||
isAdmin: true,
|
||||
areApiKeysEnabled: true,
|
||||
canManage: true,
|
||||
});
|
||||
apiClientMock.getApiKeys.mockResolvedValue({
|
||||
apiKeys: [
|
||||
{
|
||||
creation: 1571322182082,
|
||||
expiration: 1571408582082,
|
||||
id: '0QQZ2m0BO2XZwgJFuWTT',
|
||||
invalidated: false,
|
||||
name: 'my-api-key',
|
||||
realm: 'reserved',
|
||||
username: 'elastic',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
it('loads and displays API keys', async () => {
|
||||
const history = createMemoryHistory({ initialEntries: ['/'] });
|
||||
|
||||
const coreStart = coreMock.createStart();
|
||||
const renderView = () => {
|
||||
return mountWithIntl(
|
||||
<KibanaContextProvider services={coreStart}>
|
||||
<APIKeysGridPage apiKeysAPIClient={apiClientMock} notifications={coreStart.notifications} />
|
||||
</KibanaContextProvider>
|
||||
const { getByText } = render(
|
||||
<Providers services={coreStart} authc={authc} history={history}>
|
||||
<APIKeysGridPage
|
||||
apiKeysAPIClient={apiClientMock}
|
||||
notifications={coreStart.notifications}
|
||||
history={history}
|
||||
/>
|
||||
</Providers>
|
||||
);
|
||||
};
|
||||
|
||||
it('renders a loading state when fetching API keys', async () => {
|
||||
expect(renderView().find('[data-test-subj="apiKeysSectionLoading"]')).toHaveLength(1);
|
||||
await waitForElementToBeRemoved(() => getByText(/Loading API keys/));
|
||||
getByText(/first-api-key/);
|
||||
getByText(/second-api-key/);
|
||||
});
|
||||
|
||||
it('renders a callout when API keys are not enabled', async () => {
|
||||
apiClientMock.checkPrivileges.mockResolvedValue({
|
||||
isAdmin: true,
|
||||
canManage: true,
|
||||
it('displays callout when API keys are disabled', async () => {
|
||||
const history = createMemoryHistory({ initialEntries: ['/'] });
|
||||
apiClientMock.checkPrivileges.mockResolvedValueOnce({
|
||||
areApiKeysEnabled: false,
|
||||
canManage: true,
|
||||
isAdmin: true,
|
||||
});
|
||||
|
||||
const wrapper = renderView();
|
||||
await waitForRender(wrapper, (updatedWrapper) => {
|
||||
return updatedWrapper.find(NotEnabled).length > 0;
|
||||
});
|
||||
const { getByText } = render(
|
||||
<Providers services={coreStart} authc={authc} history={history}>
|
||||
<APIKeysGridPage
|
||||
apiKeysAPIClient={apiClientMock}
|
||||
notifications={coreStart.notifications}
|
||||
history={history}
|
||||
/>
|
||||
</Providers>
|
||||
);
|
||||
|
||||
expect(wrapper.find(NotEnabled).find(EuiCallOut)).toMatchSnapshot();
|
||||
await waitForElementToBeRemoved(() => getByText(/Loading API keys/));
|
||||
getByText(/API keys not enabled/);
|
||||
});
|
||||
|
||||
it('renders permission denied if user does not have required permissions', async () => {
|
||||
apiClientMock.checkPrivileges.mockResolvedValue({
|
||||
it('displays error when user does not have required permissions', async () => {
|
||||
const history = createMemoryHistory({ initialEntries: ['/'] });
|
||||
apiClientMock.checkPrivileges.mockResolvedValueOnce({
|
||||
areApiKeysEnabled: true,
|
||||
canManage: false,
|
||||
isAdmin: false,
|
||||
areApiKeysEnabled: true,
|
||||
});
|
||||
|
||||
const wrapper = renderView();
|
||||
await waitForRender(wrapper, (updatedWrapper) => {
|
||||
return updatedWrapper.find(PermissionDenied).length > 0;
|
||||
});
|
||||
const { getByText } = render(
|
||||
<Providers services={coreStart} authc={authc} history={history}>
|
||||
<APIKeysGridPage
|
||||
apiKeysAPIClient={apiClientMock}
|
||||
notifications={coreStart.notifications}
|
||||
history={history}
|
||||
/>
|
||||
</Providers>
|
||||
);
|
||||
|
||||
expect(wrapper.find(PermissionDenied)).toMatchSnapshot();
|
||||
await waitForElementToBeRemoved(() => getByText(/Loading API keys/));
|
||||
getByText(/You need permission to manage API keys/);
|
||||
});
|
||||
|
||||
it('renders error callout if error fetching API keys', async () => {
|
||||
apiClientMock.getApiKeys.mockRejectedValue(mock500());
|
||||
|
||||
const wrapper = renderView();
|
||||
await waitForRender(wrapper, (updatedWrapper) => {
|
||||
return updatedWrapper.find(EuiCallOut).length > 0;
|
||||
it('displays error when fetching API keys fails', async () => {
|
||||
apiClientMock.getApiKeys.mockRejectedValueOnce({
|
||||
body: { error: 'Internal Server Error', message: '', statusCode: 500 },
|
||||
});
|
||||
const history = createMemoryHistory({ initialEntries: ['/'] });
|
||||
|
||||
expect(wrapper.find('EuiCallOut[data-test-subj="apiKeysError"]')).toHaveLength(1);
|
||||
const { getByText } = render(
|
||||
<Providers services={coreStart} authc={authc} history={history}>
|
||||
<APIKeysGridPage
|
||||
apiKeysAPIClient={apiClientMock}
|
||||
notifications={coreStart.notifications}
|
||||
history={history}
|
||||
/>
|
||||
</Providers>
|
||||
);
|
||||
|
||||
await waitForElementToBeRemoved(() => getByText(/Loading API keys/));
|
||||
getByText(/Could not load API keys/);
|
||||
});
|
||||
|
||||
describe('Admin view', () => {
|
||||
let wrapper: ReactWrapper<any>;
|
||||
beforeEach(() => {
|
||||
wrapper = renderView();
|
||||
it('creates API key when submitting form, redirects back and displays base64', async () => {
|
||||
const history = createMemoryHistory({ initialEntries: ['/create'] });
|
||||
coreStart.http.get.mockResolvedValue([{ name: 'superuser' }]);
|
||||
coreStart.http.post.mockResolvedValue({ id: '1D', api_key: 'AP1_K3Y' });
|
||||
|
||||
const { findByRole, findByDisplayValue } = render(
|
||||
<Providers services={coreStart} authc={authc} history={history}>
|
||||
<APIKeysGridPage
|
||||
apiKeysAPIClient={apiClientMock}
|
||||
notifications={coreStart.notifications}
|
||||
history={history}
|
||||
/>
|
||||
</Providers>
|
||||
);
|
||||
expect(coreStart.http.get).toHaveBeenCalledWith('/api/security/role');
|
||||
|
||||
const dialog = await findByRole('dialog');
|
||||
|
||||
fireEvent.click(await findByRole('button', { name: 'Create API key' }));
|
||||
|
||||
const alert = await findByRole('alert');
|
||||
within(alert).getByText(/Enter a name/i);
|
||||
|
||||
fireEvent.change(await within(dialog).findByLabelText('Name'), {
|
||||
target: { value: 'Test' },
|
||||
});
|
||||
|
||||
it('renders a callout indicating the user is an administrator', async () => {
|
||||
const calloutEl = 'EuiCallOut[data-test-subj="apiKeyAdminDescriptionCallOut"]';
|
||||
fireEvent.click(await findByRole('button', { name: 'Create API key' }));
|
||||
|
||||
await waitForRender(wrapper, (updatedWrapper) => {
|
||||
return updatedWrapper.find(calloutEl).length > 0;
|
||||
await waitFor(() => {
|
||||
expect(coreStart.http.post).toHaveBeenLastCalledWith('/internal/security/api_key', {
|
||||
body: JSON.stringify({ name: 'Test' }),
|
||||
});
|
||||
|
||||
expect(wrapper.find(calloutEl).text()).toEqual('You are an API Key administrator.');
|
||||
expect(history.location.pathname).toBe('/');
|
||||
});
|
||||
|
||||
it('renders the correct description text', async () => {
|
||||
const descriptionEl = 'EuiText[data-test-subj="apiKeysDescriptionText"]';
|
||||
await findByDisplayValue(btoa('1D:AP1_K3Y'));
|
||||
});
|
||||
|
||||
await waitForRender(wrapper, (updatedWrapper) => {
|
||||
return updatedWrapper.find(descriptionEl).length > 0;
|
||||
it('creates API key with optional expiration, redirects back and displays base64', async () => {
|
||||
const history = createMemoryHistory({ initialEntries: ['/create'] });
|
||||
coreStart.http.get.mockResolvedValue([{ name: 'superuser' }]);
|
||||
coreStart.http.post.mockResolvedValue({ id: '1D', api_key: 'AP1_K3Y' });
|
||||
|
||||
const { findByRole, findByDisplayValue } = render(
|
||||
<Providers services={coreStart} authc={authc} history={history}>
|
||||
<APIKeysGridPage
|
||||
apiKeysAPIClient={apiClientMock}
|
||||
notifications={coreStart.notifications}
|
||||
history={history}
|
||||
/>
|
||||
</Providers>
|
||||
);
|
||||
expect(coreStart.http.get).toHaveBeenCalledWith('/api/security/role');
|
||||
|
||||
const dialog = await findByRole('dialog');
|
||||
|
||||
fireEvent.change(await within(dialog).findByLabelText('Name'), {
|
||||
target: { value: 'Test' },
|
||||
});
|
||||
|
||||
fireEvent.click(await within(dialog).findByLabelText('Expire after time'));
|
||||
|
||||
fireEvent.click(await findByRole('button', { name: 'Create API key' }));
|
||||
|
||||
const alert = await findByRole('alert');
|
||||
within(alert).getByText(/Enter a valid duration or disable this option\./i);
|
||||
|
||||
fireEvent.change(await within(dialog).findByLabelText('Lifetime (days)'), {
|
||||
target: { value: '12' },
|
||||
});
|
||||
|
||||
fireEvent.click(await findByRole('button', { name: 'Create API key' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(coreStart.http.post).toHaveBeenLastCalledWith('/internal/security/api_key', {
|
||||
body: JSON.stringify({ name: 'Test', expiration: '12d' }),
|
||||
});
|
||||
expect(history.location.pathname).toBe('/');
|
||||
});
|
||||
|
||||
expect(wrapper.find(descriptionEl).text()).toEqual(
|
||||
'View and invalidate API keys. An API key sends requests on behalf of a user.'
|
||||
await findByDisplayValue(btoa('1D:AP1_K3Y'));
|
||||
});
|
||||
|
||||
it('deletes api key using cta button', async () => {
|
||||
const history = createMemoryHistory({ initialEntries: ['/'] });
|
||||
|
||||
const { findByRole, findAllByLabelText } = render(
|
||||
<Providers services={coreStart} authc={authc} history={history}>
|
||||
<APIKeysGridPage
|
||||
apiKeysAPIClient={apiClientMock}
|
||||
notifications={coreStart.notifications}
|
||||
history={history}
|
||||
/>
|
||||
</Providers>
|
||||
);
|
||||
|
||||
const [deleteButton] = await findAllByLabelText(/Delete/i);
|
||||
fireEvent.click(deleteButton);
|
||||
|
||||
const dialog = await findByRole('dialog');
|
||||
fireEvent.click(await within(dialog).findByRole('button', { name: 'Delete API key' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiClientMock.invalidateApiKeys).toHaveBeenLastCalledWith(
|
||||
[{ id: '0QQZ2m0BO2XZwgJFuWTT', name: 'first-api-key' }],
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Non-admin view', () => {
|
||||
let wrapper: ReactWrapper<any>;
|
||||
beforeEach(() => {
|
||||
apiClientMock.checkPrivileges.mockResolvedValue({
|
||||
isAdmin: false,
|
||||
canManage: true,
|
||||
areApiKeysEnabled: true,
|
||||
});
|
||||
it('deletes multiple api keys using bulk select', async () => {
|
||||
const history = createMemoryHistory({ initialEntries: ['/'] });
|
||||
|
||||
wrapper = renderView();
|
||||
});
|
||||
const { findByRole, findAllByRole } = render(
|
||||
<Providers services={coreStart} authc={authc} history={history}>
|
||||
<APIKeysGridPage
|
||||
apiKeysAPIClient={apiClientMock}
|
||||
notifications={coreStart.notifications}
|
||||
history={history}
|
||||
/>
|
||||
</Providers>
|
||||
);
|
||||
|
||||
it('does NOT render a callout indicating the user is an administrator', async () => {
|
||||
const descriptionEl = 'EuiText[data-test-subj="apiKeysDescriptionText"]';
|
||||
const calloutEl = 'EuiCallOut[data-test-subj="apiKeyAdminDescriptionCallOut"]';
|
||||
const deleteCheckboxes = await findAllByRole('checkbox', { name: 'Select this row' });
|
||||
deleteCheckboxes.forEach((checkbox) => fireEvent.click(checkbox));
|
||||
fireEvent.click(await findByRole('button', { name: 'Delete API keys' }));
|
||||
|
||||
await waitForRender(wrapper, (updatedWrapper) => {
|
||||
return updatedWrapper.find(descriptionEl).length > 0;
|
||||
});
|
||||
const dialog = await findByRole('dialog');
|
||||
fireEvent.click(await within(dialog).findByRole('button', { name: 'Delete API keys' }));
|
||||
|
||||
expect(wrapper.find(calloutEl).length).toEqual(0);
|
||||
});
|
||||
|
||||
it('renders the correct description text', async () => {
|
||||
const descriptionEl = 'EuiText[data-test-subj="apiKeysDescriptionText"]';
|
||||
|
||||
await waitForRender(wrapper, (updatedWrapper) => {
|
||||
return updatedWrapper.find(descriptionEl).length > 0;
|
||||
});
|
||||
|
||||
expect(wrapper.find(descriptionEl).text()).toEqual(
|
||||
'View and invalidate your API keys. An API key sends requests on your behalf.'
|
||||
await waitFor(() => {
|
||||
expect(apiClientMock.invalidateApiKeys).toHaveBeenLastCalledWith(
|
||||
[
|
||||
{ id: '0QQZ2m0BO2XZwgJFuWTT', name: 'first-api-key' },
|
||||
{ id: 'BO2XZwgJFuWTT0QQZ2m0', name: 'second-api-key' },
|
||||
],
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -9,10 +9,11 @@ import type { EuiBasicTableColumn, EuiInMemoryTableProps } from '@elastic/eui';
|
|||
import {
|
||||
EuiBadge,
|
||||
EuiButton,
|
||||
EuiButtonIcon,
|
||||
EuiCallOut,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiHealth,
|
||||
EuiIcon,
|
||||
EuiInMemoryTable,
|
||||
EuiPageContent,
|
||||
EuiPageContentBody,
|
||||
|
@ -23,8 +24,10 @@ import {
|
|||
EuiTitle,
|
||||
EuiToolTip,
|
||||
} from '@elastic/eui';
|
||||
import type { History } from 'history';
|
||||
import moment from 'moment-timezone';
|
||||
import React, { Component } from 'react';
|
||||
import { Route } from 'react-router-dom';
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
|
@ -32,14 +35,20 @@ import type { PublicMethodsOf } from '@kbn/utility-types';
|
|||
import type { NotificationsStart } from 'src/core/public';
|
||||
|
||||
import { SectionLoading } from '../../../../../../../src/plugins/es_ui_shared/public';
|
||||
import { reactRouterNavigate } from '../../../../../../../src/plugins/kibana_react/public';
|
||||
import type { ApiKey, ApiKeyToInvalidate } from '../../../../common/model';
|
||||
import type { APIKeysAPIClient } from '../api_keys_api_client';
|
||||
import { EmptyPrompt } from './empty_prompt';
|
||||
import { Breadcrumb } from '../../../components/breadcrumb';
|
||||
import { SelectableTokenField } from '../../../components/token_field';
|
||||
import type { APIKeysAPIClient, CreateApiKeyResponse } from '../api_keys_api_client';
|
||||
import { ApiKeysEmptyPrompt } from './api_keys_empty_prompt';
|
||||
import { CreateApiKeyFlyout } from './create_api_key_flyout';
|
||||
import type { InvalidateApiKeys } from './invalidate_provider';
|
||||
import { InvalidateProvider } from './invalidate_provider';
|
||||
import { NotEnabled } from './not_enabled';
|
||||
import { PermissionDenied } from './permission_denied';
|
||||
|
||||
interface Props {
|
||||
history: History;
|
||||
notifications: NotificationsStart;
|
||||
apiKeysAPIClient: PublicMethodsOf<APIKeysAPIClient>;
|
||||
}
|
||||
|
@ -50,9 +59,10 @@ interface State {
|
|||
isAdmin: boolean;
|
||||
canManage: boolean;
|
||||
areApiKeysEnabled: boolean;
|
||||
apiKeys: ApiKey[];
|
||||
apiKeys?: ApiKey[];
|
||||
selectedItems: ApiKey[];
|
||||
error: any;
|
||||
createdApiKey?: CreateApiKeyResponse;
|
||||
}
|
||||
|
||||
const DATE_FORMAT = 'MMMM Do YYYY HH:mm:ss';
|
||||
|
@ -66,7 +76,7 @@ export class APIKeysGridPage extends Component<Props, State> {
|
|||
isAdmin: false,
|
||||
canManage: false,
|
||||
areApiKeysEnabled: false,
|
||||
apiKeys: [],
|
||||
apiKeys: undefined,
|
||||
selectedItems: [],
|
||||
error: undefined,
|
||||
};
|
||||
|
@ -77,6 +87,31 @@ export class APIKeysGridPage extends Component<Props, State> {
|
|||
}
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<div>
|
||||
<Route path="/create">
|
||||
<Breadcrumb
|
||||
text={i18n.translate('xpack.security.management.apiKeys.createBreadcrumb', {
|
||||
defaultMessage: 'Create',
|
||||
})}
|
||||
href="/create"
|
||||
>
|
||||
<CreateApiKeyFlyout
|
||||
onSuccess={(apiKey) => {
|
||||
this.props.history.push({ pathname: '/' });
|
||||
this.reloadApiKeys();
|
||||
this.setState({ createdApiKey: apiKey });
|
||||
}}
|
||||
onCancel={() => this.props.history.push({ pathname: '/' })}
|
||||
/>
|
||||
</Breadcrumb>
|
||||
</Route>
|
||||
{this.renderContent()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
public renderContent() {
|
||||
const {
|
||||
isLoadingApp,
|
||||
isLoadingTable,
|
||||
|
@ -87,104 +122,191 @@ export class APIKeysGridPage extends Component<Props, State> {
|
|||
apiKeys,
|
||||
} = this.state;
|
||||
|
||||
if (isLoadingApp) {
|
||||
return (
|
||||
<EuiPageContent>
|
||||
<SectionLoading data-test-subj="apiKeysSectionLoading">
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.apiKeys.table.loadingApiKeysDescription"
|
||||
defaultMessage="Loading API keys…"
|
||||
/>
|
||||
</SectionLoading>
|
||||
</EuiPageContent>
|
||||
);
|
||||
}
|
||||
|
||||
if (!canManage) {
|
||||
return <PermissionDenied />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
const {
|
||||
body: { error: errorTitle, message, statusCode },
|
||||
} = error;
|
||||
|
||||
return (
|
||||
<EuiPageContent>
|
||||
<EuiCallOut
|
||||
title={
|
||||
if (!apiKeys) {
|
||||
if (isLoadingApp) {
|
||||
return (
|
||||
<EuiPageContent>
|
||||
<SectionLoading>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.apiKeys.table.loadingApiKeysErrorTitle"
|
||||
defaultMessage="Error loading API keys"
|
||||
id="xpack.security.management.apiKeys.table.loadingApiKeysDescription"
|
||||
defaultMessage="Loading API keys…"
|
||||
/>
|
||||
}
|
||||
color="danger"
|
||||
iconType="alert"
|
||||
data-test-subj="apiKeysError"
|
||||
>
|
||||
{statusCode}: {errorTitle} - {message}
|
||||
</EuiCallOut>
|
||||
</EuiPageContent>
|
||||
);
|
||||
}
|
||||
</SectionLoading>
|
||||
</EuiPageContent>
|
||||
);
|
||||
}
|
||||
|
||||
if (!areApiKeysEnabled) {
|
||||
return (
|
||||
<EuiPageContent>
|
||||
<NotEnabled />
|
||||
</EuiPageContent>
|
||||
);
|
||||
if (!canManage) {
|
||||
return <PermissionDenied />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<EuiPageContent>
|
||||
<ApiKeysEmptyPrompt error={error}>
|
||||
<EuiButton iconType="refresh" onClick={this.reloadApiKeys}>
|
||||
<FormattedMessage
|
||||
id="xpack.security.accountManagement.apiKeys.retryButton"
|
||||
defaultMessage="Try again"
|
||||
/>
|
||||
</EuiButton>
|
||||
</ApiKeysEmptyPrompt>
|
||||
</EuiPageContent>
|
||||
);
|
||||
}
|
||||
|
||||
if (!areApiKeysEnabled) {
|
||||
return (
|
||||
<EuiPageContent>
|
||||
<NotEnabled />
|
||||
</EuiPageContent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isLoadingTable && apiKeys && apiKeys.length === 0) {
|
||||
return (
|
||||
<EuiPageContent>
|
||||
<EmptyPrompt isAdmin={isAdmin} />
|
||||
<ApiKeysEmptyPrompt>
|
||||
<EuiButton {...reactRouterNavigate(this.props.history, '/create')} fill>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.apiKeys.table.createButton"
|
||||
defaultMessage="Create API key"
|
||||
/>
|
||||
</EuiButton>
|
||||
</ApiKeysEmptyPrompt>
|
||||
</EuiPageContent>
|
||||
);
|
||||
}
|
||||
|
||||
const description = (
|
||||
<EuiText color="subdued" size="s" data-test-subj="apiKeysDescriptionText">
|
||||
<p>
|
||||
{isAdmin ? (
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.apiKeys.table.apiKeysAllDescription"
|
||||
defaultMessage="View and invalidate API keys. An API key sends requests on behalf of a user."
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.apiKeys.table.apiKeysOwnDescription"
|
||||
defaultMessage="View and invalidate your API keys. An API key sends requests on your behalf."
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
</EuiText>
|
||||
);
|
||||
const concatenated = `${this.state.createdApiKey?.id}:${this.state.createdApiKey?.api_key}`;
|
||||
|
||||
return (
|
||||
<EuiPageContent>
|
||||
<EuiPageContentHeader>
|
||||
<EuiPageContentHeaderSection>
|
||||
<EuiTitle>
|
||||
<h2>
|
||||
<h1>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.apiKeys.table.apiKeysTitle"
|
||||
defaultMessage="API Keys"
|
||||
/>
|
||||
</h2>
|
||||
</h1>
|
||||
</EuiTitle>
|
||||
{description}
|
||||
<EuiText color="subdued" size="s" data-test-subj="apiKeysDescriptionText">
|
||||
<p>
|
||||
{isAdmin ? (
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.apiKeys.table.apiKeysAllDescription"
|
||||
defaultMessage="View and delete API keys. An API key sends requests on behalf of a user."
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.apiKeys.table.apiKeysOwnDescription"
|
||||
defaultMessage="View and delete your API keys. An API key sends requests on your behalf."
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
</EuiText>
|
||||
</EuiPageContentHeaderSection>
|
||||
<EuiPageContentHeaderSection>
|
||||
<EuiButton {...reactRouterNavigate(this.props.history, '/create')}>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.apiKeys.table.createButton"
|
||||
defaultMessage="Create API key"
|
||||
/>
|
||||
</EuiButton>
|
||||
</EuiPageContentHeaderSection>
|
||||
</EuiPageContentHeader>
|
||||
|
||||
{this.state.createdApiKey && !this.state.isLoadingTable && (
|
||||
<>
|
||||
<EuiCallOut
|
||||
color="success"
|
||||
iconType="check"
|
||||
title={i18n.translate('xpack.security.management.apiKeys.createSuccessMessage', {
|
||||
defaultMessage: "Created API key '{name}'",
|
||||
values: { name: this.state.createdApiKey.name },
|
||||
})}
|
||||
>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.apiKeys.successDescription"
|
||||
defaultMessage="Copy this key now. You will not be able to view it again."
|
||||
/>
|
||||
</p>
|
||||
<SelectableTokenField
|
||||
options={[
|
||||
{
|
||||
key: 'base64',
|
||||
value: btoa(concatenated),
|
||||
icon: 'empty',
|
||||
label: i18n.translate('xpack.security.management.apiKeys.base64Label', {
|
||||
defaultMessage: 'Base64',
|
||||
}),
|
||||
description: i18n.translate(
|
||||
'xpack.security.management.apiKeys.base64Description',
|
||||
{
|
||||
defaultMessage: 'Format used to authenticate with Elasticsearch.',
|
||||
}
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'json',
|
||||
value: JSON.stringify(this.state.createdApiKey),
|
||||
icon: 'empty',
|
||||
label: i18n.translate('xpack.security.management.apiKeys.jsonLabel', {
|
||||
defaultMessage: 'JSON',
|
||||
}),
|
||||
description: i18n.translate(
|
||||
'xpack.security.management.apiKeys.jsonDescription',
|
||||
{
|
||||
defaultMessage: 'Full API response.',
|
||||
}
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'beats',
|
||||
value: concatenated,
|
||||
icon: 'logoBeats',
|
||||
label: i18n.translate('xpack.security.management.apiKeys.beatsLabel', {
|
||||
defaultMessage: 'Beats',
|
||||
}),
|
||||
description: i18n.translate(
|
||||
'xpack.security.management.apiKeys.beatsDescription',
|
||||
{
|
||||
defaultMessage: 'Format used to configure Beats.',
|
||||
}
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'logstash',
|
||||
value: concatenated,
|
||||
icon: 'logoLogstash',
|
||||
label: i18n.translate('xpack.security.management.apiKeys.logstashLabel', {
|
||||
defaultMessage: 'Logstash',
|
||||
}),
|
||||
description: i18n.translate(
|
||||
'xpack.security.management.apiKeys.logstashDescription',
|
||||
{
|
||||
defaultMessage: 'Format used to configure Logstash.',
|
||||
}
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</EuiCallOut>
|
||||
<EuiSpacer />
|
||||
</>
|
||||
)}
|
||||
|
||||
<EuiPageContentBody>{this.renderTable()}</EuiPageContentBody>
|
||||
</EuiPageContent>
|
||||
);
|
||||
}
|
||||
|
||||
private renderTable = () => {
|
||||
const { apiKeys, selectedItems, isLoadingTable, isAdmin } = this.state;
|
||||
const { apiKeys, selectedItems, isLoadingTable, isAdmin, error } = this.state;
|
||||
|
||||
const message = isLoadingTable ? (
|
||||
<FormattedMessage
|
||||
|
@ -195,8 +317,8 @@ export class APIKeysGridPage extends Component<Props, State> {
|
|||
|
||||
const sorting = {
|
||||
sort: {
|
||||
field: 'expiration',
|
||||
direction: 'asc',
|
||||
field: 'creation',
|
||||
direction: 'desc',
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
@ -234,7 +356,7 @@ export class APIKeysGridPage extends Component<Props, State> {
|
|||
>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.apiKeys.table.invalidateApiKeyButton"
|
||||
defaultMessage="Invalidate {count, plural, one {API key} other {API keys}}"
|
||||
defaultMessage="Delete {count, plural, one {API key} other {API keys}}"
|
||||
values={{
|
||||
count: selectedItems.length,
|
||||
}}
|
||||
|
@ -244,19 +366,6 @@ export class APIKeysGridPage extends Component<Props, State> {
|
|||
}}
|
||||
</InvalidateProvider>
|
||||
) : undefined,
|
||||
toolsRight: (
|
||||
<EuiButton
|
||||
color="secondary"
|
||||
iconType="refresh"
|
||||
onClick={() => this.reloadApiKeys()}
|
||||
data-test-subj="reloadButton"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.apiKeys.table.reloadApiKeysButton"
|
||||
defaultMessage="Reload"
|
||||
/>
|
||||
</EuiButton>
|
||||
),
|
||||
box: {
|
||||
incremental: true,
|
||||
},
|
||||
|
@ -270,14 +379,23 @@ export class APIKeysGridPage extends Component<Props, State> {
|
|||
}),
|
||||
multiSelect: false,
|
||||
options: Object.keys(
|
||||
apiKeys.reduce((apiKeysMap: any, apiKey) => {
|
||||
apiKeys?.reduce((apiKeysMap: any, apiKey) => {
|
||||
apiKeysMap[apiKey.username] = true;
|
||||
return apiKeysMap;
|
||||
}, {})
|
||||
}, {}) ?? {}
|
||||
).map((username) => {
|
||||
return {
|
||||
value: username,
|
||||
view: username,
|
||||
view: (
|
||||
<EuiFlexGroup alignItems="center" gutterSize="s" responsive={false}>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIcon type="user" />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiText>{username}</EuiText>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
),
|
||||
};
|
||||
}),
|
||||
},
|
||||
|
@ -289,10 +407,10 @@ export class APIKeysGridPage extends Component<Props, State> {
|
|||
}),
|
||||
multiSelect: false,
|
||||
options: Object.keys(
|
||||
apiKeys.reduce((apiKeysMap: any, apiKey) => {
|
||||
apiKeys?.reduce((apiKeysMap: any, apiKey) => {
|
||||
apiKeysMap[apiKey.realm] = true;
|
||||
return apiKeysMap;
|
||||
}, {})
|
||||
}, {}) ?? {}
|
||||
).map((realm) => {
|
||||
return {
|
||||
value: realm,
|
||||
|
@ -306,52 +424,58 @@ export class APIKeysGridPage extends Component<Props, State> {
|
|||
|
||||
return (
|
||||
<>
|
||||
{isAdmin ? (
|
||||
{!isAdmin ? (
|
||||
<>
|
||||
<EuiCallOut
|
||||
title={
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.apiKeys.table.adminText"
|
||||
defaultMessage="You are an API Key administrator."
|
||||
id="xpack.security.management.apiKeys.table.manageOwnKeysWarning"
|
||||
defaultMessage="You only have permission to manage your own API keys."
|
||||
/>
|
||||
}
|
||||
color="success"
|
||||
color="primary"
|
||||
iconType="user"
|
||||
size="s"
|
||||
data-test-subj="apiKeyAdminDescriptionCallOut"
|
||||
/>
|
||||
|
||||
<EuiSpacer size="m" />
|
||||
<EuiSpacer />
|
||||
</>
|
||||
) : undefined}
|
||||
|
||||
{
|
||||
<EuiInMemoryTable
|
||||
items={apiKeys}
|
||||
itemId="id"
|
||||
columns={this.getColumnConfig()}
|
||||
search={search}
|
||||
sorting={sorting}
|
||||
selection={selection}
|
||||
pagination={pagination}
|
||||
loading={isLoadingTable}
|
||||
message={message}
|
||||
isSelectable={true}
|
||||
rowProps={() => {
|
||||
return {
|
||||
'data-test-subj': 'apiKeyRow',
|
||||
};
|
||||
}}
|
||||
/>
|
||||
}
|
||||
<InvalidateProvider
|
||||
isAdmin={isAdmin}
|
||||
notifications={this.props.notifications}
|
||||
apiKeysAPIClient={this.props.apiKeysAPIClient}
|
||||
>
|
||||
{(invalidateApiKeyPrompt) => (
|
||||
<EuiInMemoryTable
|
||||
items={apiKeys ?? []}
|
||||
itemId="id"
|
||||
columns={this.getColumnConfig(invalidateApiKeyPrompt)}
|
||||
search={search}
|
||||
sorting={sorting}
|
||||
selection={selection}
|
||||
pagination={pagination}
|
||||
loading={isLoadingTable}
|
||||
error={
|
||||
error &&
|
||||
i18n.translate('xpack.security.management.apiKeysEmptyPrompt.errorMessage', {
|
||||
defaultMessage: 'Could not load API keys.',
|
||||
})
|
||||
}
|
||||
message={message}
|
||||
isSelectable={true}
|
||||
/>
|
||||
)}
|
||||
</InvalidateProvider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
private getColumnConfig = () => {
|
||||
const { isAdmin } = this.state;
|
||||
private getColumnConfig = (invalidateApiKeyPrompt: InvalidateApiKeys) => {
|
||||
const { isAdmin, createdApiKey } = this.state;
|
||||
|
||||
let config: Array<EuiBasicTableColumn<any>> = [
|
||||
let config: Array<EuiBasicTableColumn<ApiKey>> = [];
|
||||
|
||||
config = config.concat([
|
||||
{
|
||||
field: 'name',
|
||||
name: i18n.translate('xpack.security.management.apiKeys.table.nameColumnName', {
|
||||
|
@ -359,7 +483,7 @@ export class APIKeysGridPage extends Component<Props, State> {
|
|||
}),
|
||||
sortable: true,
|
||||
},
|
||||
];
|
||||
]);
|
||||
|
||||
if (isAdmin) {
|
||||
config = config.concat([
|
||||
|
@ -369,6 +493,16 @@ export class APIKeysGridPage extends Component<Props, State> {
|
|||
defaultMessage: 'User',
|
||||
}),
|
||||
sortable: true,
|
||||
render: (username: string) => (
|
||||
<EuiFlexGroup alignItems="center" gutterSize="s" responsive={false}>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIcon type="user" />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiText>{username}</EuiText>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'realm',
|
||||
|
@ -387,91 +521,83 @@ export class APIKeysGridPage extends Component<Props, State> {
|
|||
defaultMessage: 'Created',
|
||||
}),
|
||||
sortable: true,
|
||||
render: (creationDateMs: number) => moment(creationDateMs).format(DATE_FORMAT),
|
||||
},
|
||||
{
|
||||
field: 'expiration',
|
||||
name: i18n.translate('xpack.security.management.apiKeys.table.expirationDateColumnName', {
|
||||
defaultMessage: 'Expires',
|
||||
}),
|
||||
sortable: true,
|
||||
render: (expirationDateMs: number) => {
|
||||
if (expirationDateMs === undefined) {
|
||||
return (
|
||||
<EuiText color="subdued">
|
||||
{i18n.translate(
|
||||
'xpack.security.management.apiKeys.table.expirationDateNeverMessage',
|
||||
{
|
||||
defaultMessage: 'Never',
|
||||
}
|
||||
)}
|
||||
</EuiText>
|
||||
);
|
||||
}
|
||||
|
||||
return moment(expirationDateMs).format(DATE_FORMAT);
|
||||
mobileOptions: {
|
||||
show: false,
|
||||
},
|
||||
render: (creation: string, item: ApiKey) => (
|
||||
<EuiToolTip content={moment(creation).format(DATE_FORMAT)}>
|
||||
{item.id === createdApiKey?.id ? (
|
||||
<EuiBadge color="secondary">
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.apiKeys.table.createdBadge"
|
||||
defaultMessage="Just now"
|
||||
/>
|
||||
</EuiBadge>
|
||||
) : (
|
||||
<span>{moment(creation).fromNow()}</span>
|
||||
)}
|
||||
</EuiToolTip>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: i18n.translate('xpack.security.management.apiKeys.table.statusColumnName', {
|
||||
defaultMessage: 'Status',
|
||||
}),
|
||||
render: ({ expiration }: any) => {
|
||||
const now = Date.now();
|
||||
|
||||
if (now > expiration) {
|
||||
return <EuiBadge color="hollow">Expired</EuiBadge>;
|
||||
if (!expiration) {
|
||||
return (
|
||||
<EuiHealth color="primary">
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.apiKeys.table.statusActive"
|
||||
defaultMessage="Active"
|
||||
/>
|
||||
</EuiHealth>
|
||||
);
|
||||
}
|
||||
|
||||
return <EuiBadge color="secondary">Active</EuiBadge>;
|
||||
if (Date.now() > expiration) {
|
||||
return (
|
||||
<EuiHealth color="subdued">
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.apiKeys.table.statusExpired"
|
||||
defaultMessage="Expired"
|
||||
/>
|
||||
</EuiHealth>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EuiHealth color="warning">
|
||||
<EuiToolTip content={moment(expiration).format(DATE_FORMAT)}>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.apiKeys.table.statusExpires"
|
||||
defaultMessage="Expires {timeFromNow}"
|
||||
values={{
|
||||
timeFromNow: moment(expiration).fromNow(),
|
||||
}}
|
||||
/>
|
||||
</EuiToolTip>
|
||||
</EuiHealth>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: i18n.translate('xpack.security.management.apiKeys.table.actionsColumnName', {
|
||||
defaultMessage: 'Actions',
|
||||
}),
|
||||
actions: [
|
||||
{
|
||||
render: ({ name, id }: any) => {
|
||||
return (
|
||||
<EuiFlexGroup gutterSize="s">
|
||||
<EuiFlexItem>
|
||||
<InvalidateProvider
|
||||
isAdmin={isAdmin}
|
||||
notifications={this.props.notifications}
|
||||
apiKeysAPIClient={this.props.apiKeysAPIClient}
|
||||
>
|
||||
{(invalidateApiKeyPrompt) => {
|
||||
return (
|
||||
<EuiToolTip
|
||||
content={i18n.translate(
|
||||
'xpack.security.management.apiKeys.table.actionDeleteTooltip',
|
||||
{ defaultMessage: 'Invalidate' }
|
||||
)}
|
||||
>
|
||||
<EuiButtonIcon
|
||||
aria-label={i18n.translate(
|
||||
'xpack.security.management.apiKeys.table.actionDeleteAriaLabel',
|
||||
{
|
||||
defaultMessage: `Invalidate '{name}'`,
|
||||
values: { name },
|
||||
}
|
||||
)}
|
||||
iconType="minusInCircle"
|
||||
color="danger"
|
||||
data-test-subj="invalidateApiKeyButton"
|
||||
onClick={() =>
|
||||
invalidateApiKeyPrompt([{ id, name }], this.onApiKeysInvalidated)
|
||||
}
|
||||
/>
|
||||
</EuiToolTip>
|
||||
);
|
||||
}}
|
||||
</InvalidateProvider>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
},
|
||||
name: i18n.translate('xpack.security.management.apiKeys.table.deleteAction', {
|
||||
defaultMessage: 'Delete',
|
||||
}),
|
||||
description: i18n.translate(
|
||||
'xpack.security.management.apiKeys.table.deleteDescription',
|
||||
{
|
||||
defaultMessage: 'Delete this API key',
|
||||
}
|
||||
),
|
||||
icon: 'trash',
|
||||
type: 'icon',
|
||||
color: 'danger',
|
||||
onClick: (item) =>
|
||||
invalidateApiKeyPrompt([{ id: item.id, name: item.name }], this.onApiKeysInvalidated),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
@ -498,7 +624,7 @@ export class APIKeysGridPage extends Component<Props, State> {
|
|||
if (!canManage || !areApiKeysEnabled) {
|
||||
this.setState({ isLoadingApp: false });
|
||||
} else {
|
||||
this.initiallyLoadApiKeys();
|
||||
this.loadApiKeys();
|
||||
}
|
||||
} catch (e) {
|
||||
this.props.notifications.toasts.addDanger(
|
||||
|
@ -510,13 +636,13 @@ export class APIKeysGridPage extends Component<Props, State> {
|
|||
}
|
||||
}
|
||||
|
||||
private initiallyLoadApiKeys = () => {
|
||||
this.setState({ isLoadingApp: true, isLoadingTable: false });
|
||||
this.loadApiKeys();
|
||||
};
|
||||
|
||||
private reloadApiKeys = () => {
|
||||
this.setState({ apiKeys: [], isLoadingApp: false, isLoadingTable: true });
|
||||
this.setState({
|
||||
isLoadingApp: false,
|
||||
isLoadingTable: true,
|
||||
createdApiKey: undefined,
|
||||
error: undefined,
|
||||
});
|
||||
this.loadApiKeys();
|
||||
};
|
||||
|
||||
|
|
|
@ -0,0 +1,378 @@
|
|||
/*
|
||||
* 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 {
|
||||
EuiCallOut,
|
||||
EuiFieldNumber,
|
||||
EuiFieldText,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiForm,
|
||||
EuiFormFieldset,
|
||||
EuiFormRow,
|
||||
EuiIcon,
|
||||
EuiLoadingContent,
|
||||
EuiSpacer,
|
||||
EuiSwitch,
|
||||
EuiText,
|
||||
} from '@elastic/eui';
|
||||
import type { FunctionComponent } from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import useAsyncFn from 'react-use/lib/useAsyncFn';
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
|
||||
import { CodeEditorField, useKibana } from '../../../../../../../src/plugins/kibana_react/public';
|
||||
import type { ApiKeyRoleDescriptors } from '../../../../common/model';
|
||||
import { DocLink } from '../../../components/doc_link';
|
||||
import type { FormFlyoutProps } from '../../../components/form_flyout';
|
||||
import { FormFlyout } from '../../../components/form_flyout';
|
||||
import { useCurrentUser } from '../../../components/use_current_user';
|
||||
import { useForm } from '../../../components/use_form';
|
||||
import type { ValidationErrors } from '../../../components/use_form';
|
||||
import { useInitialFocus } from '../../../components/use_initial_focus';
|
||||
import { RolesAPIClient } from '../../roles/roles_api_client';
|
||||
import { APIKeysAPIClient } from '../api_keys_api_client';
|
||||
import type { CreateApiKeyRequest, CreateApiKeyResponse } from '../api_keys_api_client';
|
||||
|
||||
export interface ApiKeyFormValues {
|
||||
name: string;
|
||||
expiration: string;
|
||||
customExpiration: boolean;
|
||||
customPrivileges: boolean;
|
||||
role_descriptors: string;
|
||||
}
|
||||
|
||||
export interface CreateApiKeyFlyoutProps {
|
||||
defaultValues?: ApiKeyFormValues;
|
||||
onSuccess?: (apiKey: CreateApiKeyResponse) => void;
|
||||
onCancel: FormFlyoutProps['onCancel'];
|
||||
}
|
||||
|
||||
const defaultDefaultValues: ApiKeyFormValues = {
|
||||
name: '',
|
||||
expiration: '',
|
||||
customExpiration: false,
|
||||
customPrivileges: false,
|
||||
role_descriptors: JSON.stringify(
|
||||
{
|
||||
'role-a': {
|
||||
cluster: ['all'],
|
||||
indices: [
|
||||
{
|
||||
names: ['index-a*'],
|
||||
privileges: ['read'],
|
||||
},
|
||||
],
|
||||
},
|
||||
'role-b': {
|
||||
cluster: ['all'],
|
||||
indices: [
|
||||
{
|
||||
names: ['index-b*'],
|
||||
privileges: ['all'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
};
|
||||
|
||||
export const CreateApiKeyFlyout: FunctionComponent<CreateApiKeyFlyoutProps> = ({
|
||||
onSuccess,
|
||||
onCancel,
|
||||
defaultValues = defaultDefaultValues,
|
||||
}) => {
|
||||
const { services } = useKibana();
|
||||
const { value: currentUser, loading: isLoadingCurrentUser } = useCurrentUser();
|
||||
const [{ value: roles, loading: isLoadingRoles }, getRoles] = useAsyncFn(
|
||||
() => new RolesAPIClient(services.http!).getRoles(),
|
||||
[services.http]
|
||||
);
|
||||
const [form, eventHandlers] = useForm({
|
||||
onSubmit: async (values) => {
|
||||
try {
|
||||
const apiKey = await new APIKeysAPIClient(services.http!).createApiKey(mapValues(values));
|
||||
onSuccess?.(apiKey);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
validate,
|
||||
defaultValues,
|
||||
});
|
||||
const isLoading = isLoadingCurrentUser || isLoadingRoles;
|
||||
|
||||
useEffect(() => {
|
||||
getRoles();
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUser && roles) {
|
||||
const userPermissions = currentUser.roles.reduce<ApiKeyRoleDescriptors>(
|
||||
(accumulator, roleName) => {
|
||||
const role = roles.find((r) => r.name === roleName);
|
||||
if (role) {
|
||||
accumulator[role.name] = role.elasticsearch;
|
||||
}
|
||||
return accumulator;
|
||||
},
|
||||
{}
|
||||
);
|
||||
if (!form.touched.role_descriptors) {
|
||||
form.setValue('role_descriptors', JSON.stringify(userPermissions, null, 2));
|
||||
}
|
||||
}
|
||||
}, [currentUser, roles]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const firstFieldRef = useInitialFocus<HTMLInputElement>([isLoading]);
|
||||
|
||||
return (
|
||||
<FormFlyout
|
||||
title={i18n.translate('xpack.security.accountManagement.createApiKey.title', {
|
||||
defaultMessage: 'Create API key',
|
||||
})}
|
||||
onCancel={onCancel}
|
||||
onSubmit={form.submit}
|
||||
submitButtonText={i18n.translate(
|
||||
'xpack.security.accountManagement.createApiKey.submitButton',
|
||||
{
|
||||
defaultMessage: '{isSubmitting, select, true{Creating API key…} other{Create API key}}',
|
||||
values: { isSubmitting: form.isSubmitting },
|
||||
}
|
||||
)}
|
||||
isLoading={form.isSubmitting}
|
||||
isDisabled={isLoading || (form.isSubmitted && form.isInvalid)}
|
||||
size="s"
|
||||
ownFocus
|
||||
>
|
||||
{form.submitError && (
|
||||
<>
|
||||
<EuiCallOut
|
||||
title={i18n.translate('xpack.security.accountManagement.createApiKey.errorMessage', {
|
||||
defaultMessage: 'Could not create API key',
|
||||
})}
|
||||
color="danger"
|
||||
>
|
||||
{(form.submitError as any).body?.message || form.submitError.message}
|
||||
</EuiCallOut>
|
||||
<EuiSpacer />
|
||||
</>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<EuiLoadingContent />
|
||||
) : (
|
||||
<EuiForm
|
||||
component="form"
|
||||
isInvalid={form.isInvalid}
|
||||
error={Object.values(form.errors)}
|
||||
invalidCallout={!form.submitError && form.isSubmitted ? 'above' : 'none'}
|
||||
{...eventHandlers}
|
||||
>
|
||||
<EuiFormRow
|
||||
label={i18n.translate(
|
||||
'xpack.security.management.users.changePasswordFlyout.userLabel',
|
||||
{
|
||||
defaultMessage: 'User',
|
||||
}
|
||||
)}
|
||||
>
|
||||
<EuiFlexGroup alignItems="center" gutterSize="s" responsive={false}>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIcon type="user" />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem style={{ overflow: 'hidden' }}>
|
||||
<EuiSpacer size="xs" />
|
||||
<EuiText
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{currentUser?.username}
|
||||
</EuiText>
|
||||
<EuiSpacer size="xs" />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFormRow>
|
||||
|
||||
<EuiFormRow
|
||||
label={i18n.translate('xpack.security.accountManagement.createApiKey.nameLabel', {
|
||||
defaultMessage: 'Name',
|
||||
})}
|
||||
helpText={i18n.translate('xpack.security.accountManagement.createApiKey.nameHelpText', {
|
||||
defaultMessage: 'What is this key used for?',
|
||||
})}
|
||||
error={form.errors.name}
|
||||
isInvalid={form.touched.name && !!form.errors.name}
|
||||
>
|
||||
<EuiFieldText
|
||||
name="name"
|
||||
defaultValue={form.values.name}
|
||||
isInvalid={form.touched.name && !!form.errors.name}
|
||||
inputRef={firstFieldRef}
|
||||
fullWidth
|
||||
/>
|
||||
</EuiFormRow>
|
||||
|
||||
<EuiSpacer />
|
||||
<EuiFormFieldset>
|
||||
<EuiSwitch
|
||||
id="apiKeyCustom"
|
||||
label={i18n.translate(
|
||||
'xpack.security.accountManagement.createApiKey.customPrivilegesLabel',
|
||||
{
|
||||
defaultMessage: 'Restrict privileges',
|
||||
}
|
||||
)}
|
||||
checked={!!form.values.customPrivileges}
|
||||
onChange={(e) => form.setValue('customPrivileges', e.target.checked)}
|
||||
/>
|
||||
{form.values.customPrivileges && (
|
||||
<>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiFormRow
|
||||
helpText={
|
||||
<DocLink
|
||||
app="elasticsearch"
|
||||
doc="security-api-create-api-key.html#security-api-create-api-key-request-body"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.security.accountManagement.createApiKey.roleDescriptorsHelpText"
|
||||
defaultMessage="Learn how to structure role descriptors."
|
||||
/>
|
||||
</DocLink>
|
||||
}
|
||||
error={form.errors.role_descriptors}
|
||||
isInvalid={form.touched.role_descriptors && !!form.errors.role_descriptors}
|
||||
>
|
||||
<CodeEditorField
|
||||
value={form.values.role_descriptors!}
|
||||
onChange={(value) => form.setValue('role_descriptors', value)}
|
||||
languageId="xjson"
|
||||
height={200}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiSpacer size="s" />
|
||||
</>
|
||||
)}
|
||||
</EuiFormFieldset>
|
||||
|
||||
<EuiSpacer />
|
||||
<EuiFormFieldset>
|
||||
<EuiSwitch
|
||||
name="customExpiration"
|
||||
label={i18n.translate(
|
||||
'xpack.security.accountManagement.createApiKey.customExpirationLabel',
|
||||
{
|
||||
defaultMessage: 'Expire after time',
|
||||
}
|
||||
)}
|
||||
checked={!!form.values.customExpiration}
|
||||
onChange={(e) => form.setValue('customExpiration', e.target.checked)}
|
||||
/>
|
||||
{form.values.customExpiration && (
|
||||
<>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiFormRow
|
||||
error={form.errors.expiration}
|
||||
isInvalid={form.touched.expiration && !!form.errors.expiration}
|
||||
label={i18n.translate(
|
||||
'xpack.security.accountManagement.createApiKey.customExpirationInputLabel',
|
||||
{
|
||||
defaultMessage: 'Lifetime (days)',
|
||||
}
|
||||
)}
|
||||
>
|
||||
<EuiFieldNumber
|
||||
append={i18n.translate(
|
||||
'xpack.security.accountManagement.createApiKey.expirationUnit',
|
||||
{
|
||||
defaultMessage: 'days',
|
||||
}
|
||||
)}
|
||||
name="expiration"
|
||||
min={0}
|
||||
defaultValue={form.values.expiration}
|
||||
isInvalid={form.touched.expiration && !!form.errors.expiration}
|
||||
fullWidth
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiSpacer size="s" />
|
||||
</>
|
||||
)}
|
||||
</EuiFormFieldset>
|
||||
|
||||
{/* Hidden submit button is required for enter key to trigger form submission */}
|
||||
<input type="submit" hidden />
|
||||
</EuiForm>
|
||||
)}
|
||||
</FormFlyout>
|
||||
);
|
||||
};
|
||||
|
||||
export function validate(values: ApiKeyFormValues) {
|
||||
const errors: ValidationErrors<ApiKeyFormValues> = {};
|
||||
|
||||
if (!values.name) {
|
||||
errors.name = i18n.translate('xpack.security.management.apiKeys.createApiKey.nameRequired', {
|
||||
defaultMessage: 'Enter a name.',
|
||||
});
|
||||
}
|
||||
|
||||
if (values.customExpiration) {
|
||||
const parsedExpiration = parseFloat(values.expiration);
|
||||
if (isNaN(parsedExpiration) || parsedExpiration <= 0) {
|
||||
errors.expiration = i18n.translate(
|
||||
'xpack.security.management.apiKeys.createApiKey.expirationRequired',
|
||||
{
|
||||
defaultMessage: 'Enter a valid duration or disable this option.',
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (values.customPrivileges) {
|
||||
if (!values.role_descriptors) {
|
||||
errors.role_descriptors = i18n.translate(
|
||||
'xpack.security.management.apiKeys.createApiKey.roleDescriptorsRequired',
|
||||
{
|
||||
defaultMessage: 'Enter role descriptors or disable this option.',
|
||||
}
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
JSON.parse(values.role_descriptors);
|
||||
} catch (e) {
|
||||
errors.role_descriptors = i18n.translate(
|
||||
'xpack.security.management.apiKeys.createApiKey.invalidJsonError',
|
||||
{
|
||||
defaultMessage: 'Enter valid JSON.',
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function mapValues(values: ApiKeyFormValues): CreateApiKeyRequest {
|
||||
return {
|
||||
name: values.name,
|
||||
expiration: values.customExpiration && values.expiration ? `${values.expiration}d` : undefined,
|
||||
role_descriptors:
|
||||
values.customPrivileges && values.role_descriptors
|
||||
? JSON.parse(values.role_descriptors)
|
||||
: undefined,
|
||||
};
|
||||
}
|
|
@ -1,76 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { EuiButton, EuiEmptyPrompt, EuiLink } from '@elastic/eui';
|
||||
import React, { Fragment } from 'react';
|
||||
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
|
||||
import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public';
|
||||
|
||||
interface Props {
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
export const EmptyPrompt: React.FunctionComponent<Props> = ({ isAdmin }) => {
|
||||
const { services } = useKibana();
|
||||
const application = services.application!;
|
||||
const docLinks = services.docLinks!;
|
||||
return (
|
||||
<EuiEmptyPrompt
|
||||
iconType="managementApp"
|
||||
title={
|
||||
<h1 data-test-subj="noApiKeysHeader">
|
||||
{isAdmin ? (
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.apiKeys.table.emptyPromptAdminTitle"
|
||||
defaultMessage="No API keys"
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.apiKeys.table.emptyPromptNonAdminTitle"
|
||||
defaultMessage="You don't have any API keys"
|
||||
/>
|
||||
)}
|
||||
</h1>
|
||||
}
|
||||
body={
|
||||
<Fragment>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.apiKeys.table.emptyPromptDescription"
|
||||
defaultMessage="You can create an {link} from Console."
|
||||
values={{
|
||||
link: (
|
||||
<EuiLink href={docLinks.links.apis.createApiKey} target="_blank">
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.apiKeys.table.emptyPromptDocsLinkMessage"
|
||||
defaultMessage="API key"
|
||||
/>
|
||||
</EuiLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</Fragment>
|
||||
}
|
||||
actions={
|
||||
<EuiButton
|
||||
type="primary"
|
||||
onClick={() => application.navigateToApp('dev_tools')}
|
||||
data-test-subj="goToConsoleButton"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.apiKeys.table.emptyPromptConsoleButtonMessage"
|
||||
defaultMessage="Go to Console"
|
||||
/>
|
||||
</EuiButton>
|
||||
}
|
||||
data-test-subj="emptyPrompt"
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -1,8 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
export { EmptyPrompt } from './empty_prompt';
|
|
@ -5,4 +5,4 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
export { InvalidateProvider } from './invalidate_provider';
|
||||
export { InvalidateProvider, InvalidateApiKeys } from './invalidate_provider';
|
||||
|
|
|
@ -41,7 +41,7 @@ export const InvalidateProvider: React.FunctionComponent<Props> = ({
|
|||
|
||||
const invalidateApiKeyPrompt: InvalidateApiKeys = (keys, onSuccess = () => undefined) => {
|
||||
if (!keys || !keys.length) {
|
||||
throw new Error('No API key IDs specified for invalidation');
|
||||
throw new Error('No API key IDs specified for deletion');
|
||||
}
|
||||
setIsModalOpen(true);
|
||||
setApiKeys(keys);
|
||||
|
@ -75,16 +75,16 @@ export const InvalidateProvider: React.FunctionComponent<Props> = ({
|
|||
const hasMultipleSuccesses = itemsInvalidated.length > 1;
|
||||
const successMessage = hasMultipleSuccesses
|
||||
? i18n.translate(
|
||||
'xpack.security.management.apiKeys.invalidateApiKey.successMultipleNotificationTitle',
|
||||
'xpack.security.management.apiKeys.deleteApiKey.successMultipleNotificationTitle',
|
||||
{
|
||||
defaultMessage: 'Invalidated {count} API keys',
|
||||
defaultMessage: 'Deleted {count} API keys',
|
||||
values: { count: itemsInvalidated.length },
|
||||
}
|
||||
)
|
||||
: i18n.translate(
|
||||
'xpack.security.management.apiKeys.invalidateApiKey.successSingleNotificationTitle',
|
||||
'xpack.security.management.apiKeys.deleteApiKey.successSingleNotificationTitle',
|
||||
{
|
||||
defaultMessage: "Invalidated API key '{name}'",
|
||||
defaultMessage: "Deleted API key '{name}'",
|
||||
values: { name: itemsInvalidated[0].name },
|
||||
}
|
||||
);
|
||||
|
@ -102,7 +102,7 @@ export const InvalidateProvider: React.FunctionComponent<Props> = ({
|
|||
const hasMultipleErrors = (errors && errors.length > 1) || (error && apiKeys.length > 1);
|
||||
const errorMessage = hasMultipleErrors
|
||||
? i18n.translate(
|
||||
'xpack.security.management.apiKeys.invalidateApiKey.errorMultipleNotificationTitle',
|
||||
'xpack.security.management.apiKeys.deleteApiKey.errorMultipleNotificationTitle',
|
||||
{
|
||||
defaultMessage: 'Error deleting {count} apiKeys',
|
||||
values: {
|
||||
|
@ -111,7 +111,7 @@ export const InvalidateProvider: React.FunctionComponent<Props> = ({
|
|||
}
|
||||
)
|
||||
: i18n.translate(
|
||||
'xpack.security.management.apiKeys.invalidateApiKey.errorSingleNotificationTitle',
|
||||
'xpack.security.management.apiKeys.deleteApiKey.errorSingleNotificationTitle',
|
||||
{
|
||||
defaultMessage: "Error deleting API key '{name}'",
|
||||
values: { name: (errors && errors[0].name) || apiKeys[0].name },
|
||||
|
@ -130,19 +130,20 @@ export const InvalidateProvider: React.FunctionComponent<Props> = ({
|
|||
|
||||
return (
|
||||
<EuiConfirmModal
|
||||
role="dialog"
|
||||
title={
|
||||
isSingle
|
||||
? i18n.translate(
|
||||
'xpack.security.management.apiKeys.invalidateApiKey.confirmModal.invalidateSingleTitle',
|
||||
'xpack.security.management.apiKeys.deleteApiKey.confirmModal.deleteSingleTitle',
|
||||
{
|
||||
defaultMessage: "Invalidate API key '{name}'?",
|
||||
defaultMessage: "Delete API key '{name}'?",
|
||||
values: { name: apiKeys[0].name },
|
||||
}
|
||||
)
|
||||
: i18n.translate(
|
||||
'xpack.security.management.apiKeys.invalidateApiKey.confirmModal.invalidateMultipleTitle',
|
||||
'xpack.security.management.apiKeys.deleteApiKey.confirmModal.deleteMultipleTitle',
|
||||
{
|
||||
defaultMessage: 'Invalidate {count} API keys?',
|
||||
defaultMessage: 'Delete {count} API keys?',
|
||||
values: { count: apiKeys.length },
|
||||
}
|
||||
)
|
||||
|
@ -150,13 +151,13 @@ export const InvalidateProvider: React.FunctionComponent<Props> = ({
|
|||
onCancel={closeModal}
|
||||
onConfirm={invalidateApiKey}
|
||||
cancelButtonText={i18n.translate(
|
||||
'xpack.security.management.apiKeys.invalidateApiKey.confirmModal.cancelButtonLabel',
|
||||
'xpack.security.management.apiKeys.deleteApiKey.confirmModal.cancelButtonLabel',
|
||||
{ defaultMessage: 'Cancel' }
|
||||
)}
|
||||
confirmButtonText={i18n.translate(
|
||||
'xpack.security.management.apiKeys.invalidateApiKey.confirmModal.confirmButtonLabel',
|
||||
'xpack.security.management.apiKeys.deleteApiKey.confirmModal.confirmButtonLabel',
|
||||
{
|
||||
defaultMessage: 'Invalidate {count, plural, one {API key} other {API keys}}',
|
||||
defaultMessage: 'Delete {count, plural, one {API key} other {API keys}}',
|
||||
values: { count: apiKeys.length },
|
||||
}
|
||||
)}
|
||||
|
@ -167,8 +168,8 @@ export const InvalidateProvider: React.FunctionComponent<Props> = ({
|
|||
<Fragment>
|
||||
<p>
|
||||
{i18n.translate(
|
||||
'xpack.security.management.apiKeys.invalidateApiKey.confirmModal.invalidateMultipleListDescription',
|
||||
{ defaultMessage: 'You are about to invalidate these API keys:' }
|
||||
'xpack.security.management.apiKeys.deleteApiKey.confirmModal.deleteMultipleListDescription',
|
||||
{ defaultMessage: 'You are about to delete these API keys:' }
|
||||
)}
|
||||
</p>
|
||||
<ul>
|
||||
|
|
|
@ -6,29 +6,36 @@
|
|||
*/
|
||||
|
||||
jest.mock('./api_keys_grid', () => ({
|
||||
APIKeysGridPage: (props: any) => `Page: ${JSON.stringify(props)}`,
|
||||
APIKeysGridPage: (props: any) => JSON.stringify(props, null, 2),
|
||||
}));
|
||||
import { coreMock, scopedHistoryMock } from 'src/core/public/mocks';
|
||||
|
||||
import { act } from '@testing-library/react';
|
||||
|
||||
import { coreMock, scopedHistoryMock } from 'src/core/public/mocks';
|
||||
import type { Unmount } from 'src/plugins/management/public/types';
|
||||
|
||||
import { securityMock } from '../../mocks';
|
||||
import { apiKeysManagementApp } from './api_keys_management_app';
|
||||
|
||||
describe('apiKeysManagementApp', () => {
|
||||
it('create() returns proper management app descriptor', () => {
|
||||
const { getStartServices } = coreMock.createSetup();
|
||||
const { authc } = securityMock.createSetup();
|
||||
|
||||
expect(apiKeysManagementApp.create({ getStartServices: getStartServices as any }))
|
||||
expect(apiKeysManagementApp.create({ authc, getStartServices: getStartServices as any }))
|
||||
.toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"id": "api_keys",
|
||||
"mount": [Function],
|
||||
"order": 30,
|
||||
"title": "API Keys",
|
||||
"title": "API keys",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('mount() works for the `grid` page', async () => {
|
||||
const { getStartServices } = coreMock.createSetup();
|
||||
const { authc } = securityMock.createSetup();
|
||||
|
||||
const startServices = await getStartServices();
|
||||
const docTitle = startServices[0].chrome.docTitle;
|
||||
|
@ -36,28 +43,54 @@ describe('apiKeysManagementApp', () => {
|
|||
const container = document.createElement('div');
|
||||
|
||||
const setBreadcrumbs = jest.fn();
|
||||
const unmount = await apiKeysManagementApp
|
||||
.create({ getStartServices: () => Promise.resolve(startServices) as any })
|
||||
.mount({
|
||||
basePath: '/some-base-path',
|
||||
element: container,
|
||||
setBreadcrumbs,
|
||||
history: scopedHistoryMock.create(),
|
||||
});
|
||||
let unmount: Unmount;
|
||||
await act(async () => {
|
||||
unmount = await apiKeysManagementApp
|
||||
.create({ authc, getStartServices: () => Promise.resolve(startServices) as any })
|
||||
.mount({
|
||||
basePath: '/some-base-path',
|
||||
element: container,
|
||||
setBreadcrumbs,
|
||||
history: scopedHistoryMock.create(),
|
||||
});
|
||||
});
|
||||
|
||||
expect(setBreadcrumbs).toHaveBeenCalledTimes(1);
|
||||
expect(setBreadcrumbs).toHaveBeenCalledWith([{ href: '/', text: 'API Keys' }]);
|
||||
expect(docTitle.change).toHaveBeenCalledWith('API Keys');
|
||||
expect(setBreadcrumbs).toHaveBeenCalledWith([{ href: '/', text: 'API keys' }]);
|
||||
expect(docTitle.change).toHaveBeenCalledWith(['API keys']);
|
||||
expect(docTitle.reset).not.toHaveBeenCalled();
|
||||
expect(container).toMatchInlineSnapshot(`
|
||||
<div>
|
||||
Page: {"notifications":{"toasts":{}},"apiKeysAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}}}
|
||||
{
|
||||
"history": {
|
||||
"action": "PUSH",
|
||||
"length": 1,
|
||||
"location": {
|
||||
"pathname": "/",
|
||||
"search": "",
|
||||
"hash": ""
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"toasts": {}
|
||||
},
|
||||
"apiKeysAPIClient": {
|
||||
"http": {
|
||||
"basePath": {
|
||||
"basePath": "",
|
||||
"serverBasePath": ""
|
||||
},
|
||||
"anonymousPaths": {},
|
||||
"externalUrl": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
`);
|
||||
|
||||
unmount();
|
||||
expect(docTitle.reset).toHaveBeenCalledTimes(1);
|
||||
unmount!();
|
||||
|
||||
expect(docTitle.reset).toHaveBeenCalledTimes(1);
|
||||
expect(container).toMatchInlineSnapshot(`<div />`);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -5,63 +5,101 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { History } from 'history';
|
||||
import type { FunctionComponent } from 'react';
|
||||
import React from 'react';
|
||||
import { render, unmountComponentAtNode } from 'react-dom';
|
||||
import { Router } from 'react-router-dom';
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import type { StartServicesAccessor } from 'src/core/public';
|
||||
import type { RegisterManagementAppArgs } from 'src/plugins/management/public';
|
||||
import { I18nProvider } from '@kbn/i18n/react';
|
||||
|
||||
import type { CoreStart, StartServicesAccessor } from '../../../../../../src/core/public';
|
||||
import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public';
|
||||
import type { RegisterManagementAppArgs } from '../../../../../../src/plugins/management/public';
|
||||
import type { AuthenticationServiceSetup } from '../../authentication';
|
||||
import type { BreadcrumbsChangeHandler } from '../../components/breadcrumb';
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbsProvider,
|
||||
createBreadcrumbsChangeHandler,
|
||||
} from '../../components/breadcrumb';
|
||||
import { AuthenticationProvider } from '../../components/use_current_user';
|
||||
import type { PluginStartDependencies } from '../../plugin';
|
||||
|
||||
interface CreateParams {
|
||||
authc: AuthenticationServiceSetup;
|
||||
getStartServices: StartServicesAccessor<PluginStartDependencies>;
|
||||
}
|
||||
|
||||
export const apiKeysManagementApp = Object.freeze({
|
||||
id: 'api_keys',
|
||||
create({ getStartServices }: CreateParams) {
|
||||
const title = i18n.translate('xpack.security.management.apiKeysTitle', {
|
||||
defaultMessage: 'API Keys',
|
||||
});
|
||||
create({ authc, getStartServices }: CreateParams) {
|
||||
return {
|
||||
id: this.id,
|
||||
order: 30,
|
||||
title,
|
||||
async mount({ element, setBreadcrumbs }) {
|
||||
setBreadcrumbs([
|
||||
{
|
||||
text: title,
|
||||
href: `/`,
|
||||
},
|
||||
]);
|
||||
|
||||
const [[core], { APIKeysGridPage }, { APIKeysAPIClient }] = await Promise.all([
|
||||
title: i18n.translate('xpack.security.management.apiKeysTitle', {
|
||||
defaultMessage: 'API keys',
|
||||
}),
|
||||
async mount({ element, setBreadcrumbs, history }) {
|
||||
const [[coreStart], { APIKeysGridPage }, { APIKeysAPIClient }] = await Promise.all([
|
||||
getStartServices(),
|
||||
import('./api_keys_grid'),
|
||||
import('./api_keys_api_client'),
|
||||
]);
|
||||
|
||||
core.chrome.docTitle.change(title);
|
||||
|
||||
render(
|
||||
<KibanaContextProvider services={core}>
|
||||
<core.i18n.Context>
|
||||
<Providers
|
||||
services={coreStart}
|
||||
history={history}
|
||||
authc={authc}
|
||||
onChange={createBreadcrumbsChangeHandler(coreStart.chrome, setBreadcrumbs)}
|
||||
>
|
||||
<Breadcrumb
|
||||
text={i18n.translate('xpack.security.management.apiKeysTitle', {
|
||||
defaultMessage: 'API keys',
|
||||
})}
|
||||
href="/"
|
||||
>
|
||||
<APIKeysGridPage
|
||||
notifications={core.notifications}
|
||||
apiKeysAPIClient={new APIKeysAPIClient(core.http)}
|
||||
history={history}
|
||||
notifications={coreStart.notifications}
|
||||
apiKeysAPIClient={new APIKeysAPIClient(coreStart.http)}
|
||||
/>
|
||||
</core.i18n.Context>
|
||||
</KibanaContextProvider>,
|
||||
</Breadcrumb>
|
||||
</Providers>,
|
||||
element
|
||||
);
|
||||
|
||||
return () => {
|
||||
core.chrome.docTitle.reset();
|
||||
unmountComponentAtNode(element);
|
||||
};
|
||||
},
|
||||
} as RegisterManagementAppArgs;
|
||||
},
|
||||
});
|
||||
|
||||
export interface ProvidersProps {
|
||||
services: CoreStart;
|
||||
history: History;
|
||||
authc: AuthenticationServiceSetup;
|
||||
onChange?: BreadcrumbsChangeHandler;
|
||||
}
|
||||
|
||||
export const Providers: FunctionComponent<ProvidersProps> = ({
|
||||
services,
|
||||
history,
|
||||
authc,
|
||||
onChange,
|
||||
children,
|
||||
}) => (
|
||||
<KibanaContextProvider services={services}>
|
||||
<AuthenticationProvider authc={authc}>
|
||||
<I18nProvider>
|
||||
<Router history={history}>
|
||||
<BreadcrumbsProvider onChange={onChange}>{children}</BreadcrumbsProvider>
|
||||
</Router>
|
||||
</I18nProvider>
|
||||
</AuthenticationProvider>
|
||||
</KibanaContextProvider>
|
||||
);
|
||||
|
|
|
@ -68,7 +68,7 @@ describe('ManagementService', () => {
|
|||
id: 'api_keys',
|
||||
mount: expect.any(Function),
|
||||
order: 30,
|
||||
title: 'API Keys',
|
||||
title: 'API keys',
|
||||
});
|
||||
expect(mockSection.registerApp).toHaveBeenCalledWith({
|
||||
id: 'role_mappings',
|
||||
|
|
|
@ -47,7 +47,7 @@ export class ManagementService {
|
|||
this.securitySection.registerApp(
|
||||
rolesManagementApp.create({ fatalErrors, license, getStartServices })
|
||||
);
|
||||
this.securitySection.registerApp(apiKeysManagementApp.create({ getStartServices }));
|
||||
this.securitySection.registerApp(apiKeysManagementApp.create({ authc, getStartServices }));
|
||||
this.securitySection.registerApp(roleMappingsManagementApp.create({ getStartServices }));
|
||||
}
|
||||
|
||||
|
|
|
@ -28,6 +28,7 @@ import { FormFlyout } from '../../../components/form_flyout';
|
|||
import { useCurrentUser } from '../../../components/use_current_user';
|
||||
import type { ValidationErrors } from '../../../components/use_form';
|
||||
import { useForm } from '../../../components/use_form';
|
||||
import { useInitialFocus } from '../../../components/use_initial_focus';
|
||||
import { UserAPIClient } from '../user_api_client';
|
||||
|
||||
export interface ChangePasswordFormValues {
|
||||
|
@ -147,6 +148,8 @@ export const ChangePasswordFlyout: FunctionComponent<ChangePasswordFlyoutProps>
|
|||
defaultValues,
|
||||
});
|
||||
|
||||
const firstFieldRef = useInitialFocus<HTMLInputElement>([isLoading]);
|
||||
|
||||
return (
|
||||
<FormFlyout
|
||||
title={i18n.translate('xpack.security.management.users.changePasswordFlyout.title', {
|
||||
|
@ -242,6 +245,7 @@ export const ChangePasswordFlyout: FunctionComponent<ChangePasswordFlyoutProps>
|
|||
defaultValue={form.values.current_password}
|
||||
isInvalid={form.touched.current_password && !!form.errors.current_password}
|
||||
autoComplete="current-password"
|
||||
inputRef={firstFieldRef}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
) : null}
|
||||
|
@ -263,6 +267,7 @@ export const ChangePasswordFlyout: FunctionComponent<ChangePasswordFlyoutProps>
|
|||
defaultValue={form.values.password}
|
||||
isInvalid={form.touched.password && !!form.errors.password}
|
||||
autoComplete="new-password"
|
||||
inputRef={isCurrentUser ? undefined : firstFieldRef}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiFormRow
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { EuiText } from '@elastic/eui';
|
||||
import { EuiConfirmModal, EuiText } from '@elastic/eui';
|
||||
import type { FunctionComponent } from 'react';
|
||||
import React from 'react';
|
||||
import useAsyncFn from 'react-use/lib/useAsyncFn';
|
||||
|
@ -13,9 +13,8 @@ import useAsyncFn from 'react-use/lib/useAsyncFn';
|
|||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
|
||||
import { UserAPIClient } from '..';
|
||||
import { useKibana } from '../../../../../../../src/plugins/kibana_react/public';
|
||||
import { ConfirmModal } from '../../../components/confirm_modal';
|
||||
import { UserAPIClient } from '../user_api_client';
|
||||
|
||||
export interface ConfirmDeleteUsersProps {
|
||||
usernames: string[];
|
||||
|
@ -54,13 +53,20 @@ export const ConfirmDeleteUsers: FunctionComponent<ConfirmDeleteUsersProps> = ({
|
|||
}, [services.http]);
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
<EuiConfirmModal
|
||||
role="dialog"
|
||||
title={i18n.translate('xpack.security.management.users.confirmDeleteUsers.title', {
|
||||
defaultMessage: "Delete {count, plural, one{user '{username}'} other{{count} users}}?",
|
||||
values: { count: usernames.length, username: usernames[0] },
|
||||
})}
|
||||
onCancel={onCancel}
|
||||
onConfirm={deleteUsers}
|
||||
cancelButtonText={i18n.translate(
|
||||
'xpack.security.management.users.confirmDeleteUsers.cancelButton',
|
||||
{
|
||||
defaultMessage: 'Cancel',
|
||||
}
|
||||
)}
|
||||
confirmButtonText={i18n.translate(
|
||||
'xpack.security.management.users.confirmDeleteUsers.confirmButton',
|
||||
{
|
||||
|
@ -69,7 +75,7 @@ export const ConfirmDeleteUsers: FunctionComponent<ConfirmDeleteUsersProps> = ({
|
|||
values: { count: usernames.length, isLoading: state.loading },
|
||||
}
|
||||
)}
|
||||
confirmButtonColor="danger"
|
||||
buttonColor="danger"
|
||||
isLoading={state.loading}
|
||||
>
|
||||
<EuiText>
|
||||
|
@ -94,6 +100,6 @@ export const ConfirmDeleteUsers: FunctionComponent<ConfirmDeleteUsersProps> = ({
|
|||
/>
|
||||
</p>
|
||||
</EuiText>
|
||||
</ConfirmModal>
|
||||
</EuiConfirmModal>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { EuiText } from '@elastic/eui';
|
||||
import { EuiConfirmModal, EuiText } from '@elastic/eui';
|
||||
import type { FunctionComponent } from 'react';
|
||||
import React from 'react';
|
||||
import useAsyncFn from 'react-use/lib/useAsyncFn';
|
||||
|
@ -13,9 +13,8 @@ import useAsyncFn from 'react-use/lib/useAsyncFn';
|
|||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
|
||||
import { UserAPIClient } from '..';
|
||||
import { useKibana } from '../../../../../../../src/plugins/kibana_react/public';
|
||||
import { ConfirmModal } from '../../../components/confirm_modal';
|
||||
import { UserAPIClient } from '../user_api_client';
|
||||
|
||||
export interface ConfirmDisableUsersProps {
|
||||
usernames: string[];
|
||||
|
@ -58,13 +57,20 @@ export const ConfirmDisableUsers: FunctionComponent<ConfirmDisableUsersProps> =
|
|||
}, [services.http]);
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
<EuiConfirmModal
|
||||
role="dialog"
|
||||
title={i18n.translate('xpack.security.management.users.confirmDisableUsers.title', {
|
||||
defaultMessage: "Deactivate {count, plural, one{user '{username}'} other{{count} users}}?",
|
||||
values: { count: usernames.length, username: usernames[0] },
|
||||
})}
|
||||
onCancel={onCancel}
|
||||
onConfirm={disableUsers}
|
||||
cancelButtonText={i18n.translate(
|
||||
'xpack.security.management.users.confirmDisableUsers.cancelButton',
|
||||
{
|
||||
defaultMessage: 'Cancel',
|
||||
}
|
||||
)}
|
||||
confirmButtonText={
|
||||
isSystemUser
|
||||
? i18n.translate(
|
||||
|
@ -81,7 +87,7 @@ export const ConfirmDisableUsers: FunctionComponent<ConfirmDisableUsersProps> =
|
|||
values: { count: usernames.length, isLoading: state.loading },
|
||||
})
|
||||
}
|
||||
confirmButtonColor={isSystemUser ? 'danger' : undefined}
|
||||
buttonColor={isSystemUser ? 'danger' : undefined}
|
||||
isLoading={state.loading}
|
||||
>
|
||||
{isSystemUser ? (
|
||||
|
@ -89,7 +95,7 @@ export const ConfirmDisableUsers: FunctionComponent<ConfirmDisableUsersProps> =
|
|||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.users.confirmDisableUsers.systemUserWarning"
|
||||
defaultMessage="Deactivating the system user will prevent Kibana from communicating with Elasticsearch."
|
||||
defaultMessage="Deactivating this user will prevent Kibana from communicating with Elasticsearch."
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
|
@ -117,6 +123,6 @@ export const ConfirmDisableUsers: FunctionComponent<ConfirmDisableUsersProps> =
|
|||
)}
|
||||
</EuiText>
|
||||
)}
|
||||
</ConfirmModal>
|
||||
</EuiConfirmModal>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { EuiText } from '@elastic/eui';
|
||||
import { EuiConfirmModal, EuiText } from '@elastic/eui';
|
||||
import type { FunctionComponent } from 'react';
|
||||
import React from 'react';
|
||||
import useAsyncFn from 'react-use/lib/useAsyncFn';
|
||||
|
@ -13,9 +13,8 @@ import useAsyncFn from 'react-use/lib/useAsyncFn';
|
|||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
|
||||
import { UserAPIClient } from '..';
|
||||
import { useKibana } from '../../../../../../../src/plugins/kibana_react/public';
|
||||
import { ConfirmModal } from '../../../components/confirm_modal';
|
||||
import { UserAPIClient } from '../user_api_client';
|
||||
|
||||
export interface ConfirmEnableUsersProps {
|
||||
usernames: string[];
|
||||
|
@ -54,13 +53,20 @@ export const ConfirmEnableUsers: FunctionComponent<ConfirmEnableUsersProps> = ({
|
|||
}, [services.http]);
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
<EuiConfirmModal
|
||||
role="dialog"
|
||||
title={i18n.translate('xpack.security.management.users.confirmEnableUsers.title', {
|
||||
defaultMessage: "Activate {count, plural, one{user '{username}'} other{{count} users}}?",
|
||||
values: { count: usernames.length, username: usernames[0] },
|
||||
})}
|
||||
onCancel={onCancel}
|
||||
onConfirm={enableUsers}
|
||||
cancelButtonText={i18n.translate(
|
||||
'xpack.security.management.users.confirmEnableUsers.cancelButton',
|
||||
{
|
||||
defaultMessage: 'Cancel',
|
||||
}
|
||||
)}
|
||||
confirmButtonText={i18n.translate(
|
||||
'xpack.security.management.users.confirmEnableUsers.confirmButton',
|
||||
{
|
||||
|
@ -87,6 +93,6 @@ export const ConfirmEnableUsers: FunctionComponent<ConfirmEnableUsersProps> = ({
|
|||
</ul>
|
||||
)}
|
||||
</EuiText>
|
||||
</ConfirmModal>
|
||||
</EuiConfirmModal>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -20,7 +20,11 @@ import type { RegisterManagementAppArgs } from 'src/plugins/management/public';
|
|||
import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public';
|
||||
import type { AuthenticationServiceSetup } from '../../authentication';
|
||||
import type { BreadcrumbsChangeHandler } from '../../components/breadcrumb';
|
||||
import { Breadcrumb, BreadcrumbsProvider, getDocTitle } from '../../components/breadcrumb';
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbsProvider,
|
||||
createBreadcrumbsChangeHandler,
|
||||
} from '../../components/breadcrumb';
|
||||
import { AuthenticationProvider } from '../../components/use_current_user';
|
||||
import type { PluginStartDependencies } from '../../plugin';
|
||||
import { tryDecodeURIComponent } from '../url_utils';
|
||||
|
@ -64,10 +68,7 @@ export const usersManagementApp = Object.freeze({
|
|||
services={coreStart}
|
||||
history={history}
|
||||
authc={authc}
|
||||
onChange={(breadcrumbs) => {
|
||||
setBreadcrumbs(breadcrumbs);
|
||||
coreStart.chrome.docTitle.change(getDocTitle(breadcrumbs));
|
||||
}}
|
||||
onChange={createBreadcrumbsChangeHandler(coreStart.chrome, setBreadcrumbs)}
|
||||
>
|
||||
<Breadcrumb
|
||||
text={i18n.translate('xpack.security.users.breadcrumb', {
|
||||
|
|
|
@ -161,6 +161,9 @@ export class APIKeys {
|
|||
|
||||
/**
|
||||
* Tries to create an API key for the current user.
|
||||
*
|
||||
* Returns newly created API key or `null` if API keys are disabled.
|
||||
*
|
||||
* @param request Request instance.
|
||||
* @param params The params to create an API key
|
||||
*/
|
||||
|
|
133
x-pack/plugins/security/server/routes/api_keys/create.test.ts
Normal file
133
x-pack/plugins/security/server/routes/api_keys/create.test.ts
Normal file
|
@ -0,0 +1,133 @@
|
|||
/*
|
||||
* 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 Boom from '@hapi/boom';
|
||||
|
||||
import type { DeeplyMockedKeys } from '@kbn/utility-types/jest';
|
||||
import type { RequestHandler } from 'src/core/server';
|
||||
import { kibanaResponseFactory } from 'src/core/server';
|
||||
import { httpServerMock } from 'src/core/server/mocks';
|
||||
|
||||
import type { AuthenticationServiceStart } from '../../authentication';
|
||||
import { authenticationServiceMock } from '../../authentication/authentication_service.mock';
|
||||
import type { SecurityRequestHandlerContext } from '../../types';
|
||||
import { routeDefinitionParamsMock } from '../index.mock';
|
||||
import { defineCreateApiKeyRoutes } from './create';
|
||||
|
||||
describe('Create API Key route', () => {
|
||||
function getMockContext(
|
||||
licenseCheckResult: { state: string; message?: string } = { state: 'valid' }
|
||||
) {
|
||||
return ({
|
||||
licensing: { license: { check: jest.fn().mockReturnValue(licenseCheckResult) } },
|
||||
} as unknown) as SecurityRequestHandlerContext;
|
||||
}
|
||||
|
||||
let routeHandler: RequestHandler<any, any, any, any>;
|
||||
let authc: DeeplyMockedKeys<AuthenticationServiceStart>;
|
||||
beforeEach(() => {
|
||||
authc = authenticationServiceMock.createStart();
|
||||
const mockRouteDefinitionParams = routeDefinitionParamsMock.create();
|
||||
mockRouteDefinitionParams.getAuthenticationService.mockReturnValue(authc);
|
||||
|
||||
defineCreateApiKeyRoutes(mockRouteDefinitionParams);
|
||||
|
||||
const [, apiKeyRouteHandler] = mockRouteDefinitionParams.router.post.mock.calls.find(
|
||||
([{ path }]) => path === '/internal/security/api_key'
|
||||
)!;
|
||||
routeHandler = apiKeyRouteHandler;
|
||||
});
|
||||
|
||||
describe('failure', () => {
|
||||
test('returns result of license checker', async () => {
|
||||
const mockContext = getMockContext({ state: 'invalid', message: 'test forbidden message' });
|
||||
const response = await routeHandler(
|
||||
mockContext,
|
||||
httpServerMock.createKibanaRequest(),
|
||||
kibanaResponseFactory
|
||||
);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.payload).toEqual({ message: 'test forbidden message' });
|
||||
expect(mockContext.licensing.license.check).toHaveBeenCalledWith('security', 'basic');
|
||||
});
|
||||
|
||||
test('returns error from cluster client', async () => {
|
||||
const error = Boom.notAcceptable('test not acceptable message');
|
||||
authc.apiKeys.create.mockRejectedValue(error);
|
||||
|
||||
const response = await routeHandler(
|
||||
getMockContext(),
|
||||
httpServerMock.createKibanaRequest(),
|
||||
kibanaResponseFactory
|
||||
);
|
||||
|
||||
expect(response.status).toBe(406);
|
||||
expect(response.payload).toEqual(error);
|
||||
});
|
||||
});
|
||||
|
||||
describe('success', () => {
|
||||
test('allows an API Key to be created', async () => {
|
||||
authc.apiKeys.create.mockResolvedValue({
|
||||
api_key: 'abc123',
|
||||
id: 'key_id',
|
||||
name: 'my api key',
|
||||
});
|
||||
|
||||
const payload = {
|
||||
name: 'my api key',
|
||||
expires: '12d',
|
||||
role_descriptors: {
|
||||
role_1: {},
|
||||
},
|
||||
};
|
||||
|
||||
const request = httpServerMock.createKibanaRequest({
|
||||
body: {
|
||||
...payload,
|
||||
},
|
||||
});
|
||||
|
||||
const response = await routeHandler(getMockContext(), request, kibanaResponseFactory);
|
||||
expect(authc.apiKeys.create).toHaveBeenCalledWith(request, payload);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.payload).toEqual({
|
||||
api_key: 'abc123',
|
||||
id: 'key_id',
|
||||
name: 'my api key',
|
||||
});
|
||||
});
|
||||
|
||||
test('returns a message if API Keys are disabled', async () => {
|
||||
authc.apiKeys.create.mockResolvedValue(null);
|
||||
|
||||
const payload = {
|
||||
name: 'my api key',
|
||||
expires: '12d',
|
||||
role_descriptors: {
|
||||
role_1: {},
|
||||
},
|
||||
};
|
||||
|
||||
const request = httpServerMock.createKibanaRequest({
|
||||
body: {
|
||||
...payload,
|
||||
},
|
||||
});
|
||||
|
||||
const response = await routeHandler(getMockContext(), request, kibanaResponseFactory);
|
||||
expect(authc.apiKeys.create).toHaveBeenCalledWith(request, payload);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.payload).toEqual({
|
||||
message: 'API Keys are not available',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
49
x-pack/plugins/security/server/routes/api_keys/create.ts
Normal file
49
x-pack/plugins/security/server/routes/api_keys/create.ts
Normal file
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { schema } from '@kbn/config-schema';
|
||||
|
||||
import type { RouteDefinitionParams } from '..';
|
||||
import { wrapIntoCustomErrorResponse } from '../../errors';
|
||||
import { createLicensedRouteHandler } from '../licensed_route_handler';
|
||||
|
||||
export function defineCreateApiKeyRoutes({
|
||||
router,
|
||||
getAuthenticationService,
|
||||
}: RouteDefinitionParams) {
|
||||
router.post(
|
||||
{
|
||||
path: '/internal/security/api_key',
|
||||
validate: {
|
||||
body: schema.object({
|
||||
name: schema.string(),
|
||||
expiration: schema.maybe(schema.string()),
|
||||
role_descriptors: schema.recordOf(
|
||||
schema.string(),
|
||||
schema.object({}, { unknowns: 'allow' }),
|
||||
{
|
||||
defaultValue: {},
|
||||
}
|
||||
),
|
||||
}),
|
||||
},
|
||||
},
|
||||
createLicensedRouteHandler(async (context, request, response) => {
|
||||
try {
|
||||
const apiKey = await getAuthenticationService().apiKeys.create(request, request.body);
|
||||
|
||||
if (!apiKey) {
|
||||
return response.badRequest({ body: { message: `API Keys are not available` } });
|
||||
}
|
||||
|
||||
return response.ok({ body: apiKey });
|
||||
} catch (error) {
|
||||
return response.customError(wrapIntoCustomErrorResponse(error));
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
|
@ -6,6 +6,7 @@
|
|||
*/
|
||||
|
||||
import type { RouteDefinitionParams } from '../';
|
||||
import { defineCreateApiKeyRoutes } from './create';
|
||||
import { defineEnabledApiKeysRoutes } from './enabled';
|
||||
import { defineGetApiKeysRoutes } from './get';
|
||||
import { defineInvalidateApiKeysRoutes } from './invalidate';
|
||||
|
@ -14,6 +15,7 @@ import { defineCheckPrivilegesRoutes } from './privileges';
|
|||
export function defineApiKeysRoutes(params: RouteDefinitionParams) {
|
||||
defineEnabledApiKeysRoutes(params);
|
||||
defineGetApiKeysRoutes(params);
|
||||
defineCreateApiKeyRoutes(params);
|
||||
defineCheckPrivilegesRoutes(params);
|
||||
defineInvalidateApiKeysRoutes(params);
|
||||
}
|
||||
|
|
|
@ -17385,7 +17385,6 @@
|
|||
"xpack.security.components.sessionIdleTimeoutWarning.title": "警告",
|
||||
"xpack.security.components.sessionLifespanWarning.message": "セッションは最大時間制限{timeout}に達しました。もう一度ログインする必要があります。",
|
||||
"xpack.security.components.sessionLifespanWarning.title": "警告",
|
||||
"xpack.security.confirmModal.cancelButton": "キャンセル",
|
||||
"xpack.security.conflictingSessionError": "申し訳ありません。すでに有効なKibanaセッションがあります。新しいセッションを開始する場合は、先に既存のセッションからログアウトしてください。",
|
||||
"xpack.security.formFlyout.cancelButton": "キャンセル",
|
||||
"xpack.security.loggedOut.login": "ログイン",
|
||||
|
@ -17421,19 +17420,7 @@
|
|||
"xpack.security.loginWithElasticsearchLabel": "Elasticsearchでログイン",
|
||||
"xpack.security.logoutAppTitle": "ログアウト",
|
||||
"xpack.security.management.apiKeys.deniedPermissionTitle": "API キーを管理するにはパーミッションが必要です",
|
||||
"xpack.security.management.apiKeys.invalidateApiKey.confirmModal.cancelButtonLabel": "キャンセル",
|
||||
"xpack.security.management.apiKeys.invalidateApiKey.confirmModal.invalidateMultipleListDescription": "これらの API キーを無効化しようとしています:",
|
||||
"xpack.security.management.apiKeys.invalidateApiKey.confirmModal.invalidateMultipleTitle": "{count} API キーを無効にしますか?",
|
||||
"xpack.security.management.apiKeys.invalidateApiKey.confirmModal.invalidateSingleTitle": "API キー「{name}」を無効にしますか?",
|
||||
"xpack.security.management.apiKeys.invalidateApiKey.errorMultipleNotificationTitle": "{count} 件の API キーの削除中にエラーが発生",
|
||||
"xpack.security.management.apiKeys.invalidateApiKey.errorSingleNotificationTitle": "API キー「{name}」の削除中にエラーが発生",
|
||||
"xpack.security.management.apiKeys.invalidateApiKey.successMultipleNotificationTitle": "無効な {count} API キー",
|
||||
"xpack.security.management.apiKeys.invalidateApiKey.successSingleNotificationTitle": "API キー「{name}」を無効にしました",
|
||||
"xpack.security.management.apiKeys.noPermissionToManageRolesDescription": "システム管理者にお問い合わせください。",
|
||||
"xpack.security.management.apiKeys.table.actionDeleteAriaLabel": "「{name}」を無効にする",
|
||||
"xpack.security.management.apiKeys.table.actionDeleteTooltip": "無効にする",
|
||||
"xpack.security.management.apiKeys.table.actionsColumnName": "アクション",
|
||||
"xpack.security.management.apiKeys.table.adminText": "あなたは API キー管理者です。",
|
||||
"xpack.security.management.apiKeys.table.apiKeysAllDescription": "API キーを表示して無効にします。API キーはユーザーの代わりにリクエストを送信します。",
|
||||
"xpack.security.management.apiKeys.table.apiKeysDisabledErrorDescription": "システム管理者に連絡し、{link}を参照して API キーを有効にしてください。",
|
||||
"xpack.security.management.apiKeys.table.apiKeysDisabledErrorLinkText": "ドキュメント",
|
||||
|
@ -17442,20 +17429,10 @@
|
|||
"xpack.security.management.apiKeys.table.apiKeysTableLoadingMessage": "API キーを読み込み中…",
|
||||
"xpack.security.management.apiKeys.table.apiKeysTitle": "API キー",
|
||||
"xpack.security.management.apiKeys.table.creationDateColumnName": "作成済み",
|
||||
"xpack.security.management.apiKeys.table.emptyPromptAdminTitle": "API キーがありません",
|
||||
"xpack.security.management.apiKeys.table.emptyPromptConsoleButtonMessage": "コンソールに移動してください",
|
||||
"xpack.security.management.apiKeys.table.emptyPromptDescription": "コンソールで {link} を作成できます。",
|
||||
"xpack.security.management.apiKeys.table.emptyPromptDocsLinkMessage": "API キー",
|
||||
"xpack.security.management.apiKeys.table.emptyPromptNonAdminTitle": "まだ API キーがありません",
|
||||
"xpack.security.management.apiKeys.table.expirationDateColumnName": "有効期限",
|
||||
"xpack.security.management.apiKeys.table.expirationDateNeverMessage": "なし",
|
||||
"xpack.security.management.apiKeys.table.fetchingApiKeysErrorMessage": "権限の確認エラー:{message}",
|
||||
"xpack.security.management.apiKeys.table.loadingApiKeysDescription": "API キーを読み込み中…",
|
||||
"xpack.security.management.apiKeys.table.loadingApiKeysErrorTitle": "API キーを読み込み中にエラーが発生",
|
||||
"xpack.security.management.apiKeys.table.nameColumnName": "名前",
|
||||
"xpack.security.management.apiKeys.table.realmColumnName": "レルム",
|
||||
"xpack.security.management.apiKeys.table.realmFilterLabel": "レルム",
|
||||
"xpack.security.management.apiKeys.table.reloadApiKeysButton": "再読み込み",
|
||||
"xpack.security.management.apiKeys.table.statusColumnName": "ステータス",
|
||||
"xpack.security.management.apiKeys.table.userFilterLabel": "ユーザー",
|
||||
"xpack.security.management.apiKeys.table.userNameColumnName": "ユーザー",
|
||||
|
|
|
@ -17625,7 +17625,6 @@
|
|||
"xpack.security.components.sessionIdleTimeoutWarning.title": "警告",
|
||||
"xpack.security.components.sessionLifespanWarning.message": "您的会话将达到最大时间限制 {timeout}。您将需要重新登录。",
|
||||
"xpack.security.components.sessionLifespanWarning.title": "警告",
|
||||
"xpack.security.confirmModal.cancelButton": "取消",
|
||||
"xpack.security.conflictingSessionError": "抱歉,您已有活动的 Kibana 会话。如果希望开始新的会话,请首先从现有会话注销。",
|
||||
"xpack.security.formFlyout.cancelButton": "取消",
|
||||
"xpack.security.loggedOut.login": "登录",
|
||||
|
@ -17661,20 +17660,7 @@
|
|||
"xpack.security.loginWithElasticsearchLabel": "通过 Elasticsearch 登录",
|
||||
"xpack.security.logoutAppTitle": "注销",
|
||||
"xpack.security.management.apiKeys.deniedPermissionTitle": "您需要管理 API 密钥的权限",
|
||||
"xpack.security.management.apiKeys.invalidateApiKey.confirmModal.cancelButtonLabel": "取消",
|
||||
"xpack.security.management.apiKeys.invalidateApiKey.confirmModal.confirmButtonLabel": "作废 {count, plural, other {API 密钥}}",
|
||||
"xpack.security.management.apiKeys.invalidateApiKey.confirmModal.invalidateMultipleListDescription": "您即将作废以下 API 密钥:",
|
||||
"xpack.security.management.apiKeys.invalidateApiKey.confirmModal.invalidateMultipleTitle": "作废 {count} 个 API 密钥?",
|
||||
"xpack.security.management.apiKeys.invalidateApiKey.confirmModal.invalidateSingleTitle": "作废 API 密钥“{name}”?",
|
||||
"xpack.security.management.apiKeys.invalidateApiKey.errorMultipleNotificationTitle": "删除 {count} 个 API 密钥时出错",
|
||||
"xpack.security.management.apiKeys.invalidateApiKey.errorSingleNotificationTitle": "删除 API 密钥“{name}”时出错",
|
||||
"xpack.security.management.apiKeys.invalidateApiKey.successMultipleNotificationTitle": "已作废 {count} 个 API 密钥",
|
||||
"xpack.security.management.apiKeys.invalidateApiKey.successSingleNotificationTitle": "已作废 API 密钥“{name}”",
|
||||
"xpack.security.management.apiKeys.noPermissionToManageRolesDescription": "请联系您的系统管理员。",
|
||||
"xpack.security.management.apiKeys.table.actionDeleteAriaLabel": "作废“{name}”",
|
||||
"xpack.security.management.apiKeys.table.actionDeleteTooltip": "作废",
|
||||
"xpack.security.management.apiKeys.table.actionsColumnName": "操作",
|
||||
"xpack.security.management.apiKeys.table.adminText": "您是 API 密钥管理员。",
|
||||
"xpack.security.management.apiKeys.table.apiKeysAllDescription": "查看并作废 API 密钥。API 密钥代表用户发送请求。",
|
||||
"xpack.security.management.apiKeys.table.apiKeysDisabledErrorDescription": "请联系您的系统管理员并参阅{link}以启用 API 密钥。",
|
||||
"xpack.security.management.apiKeys.table.apiKeysDisabledErrorLinkText": "文档",
|
||||
|
@ -17683,21 +17669,11 @@
|
|||
"xpack.security.management.apiKeys.table.apiKeysTableLoadingMessage": "正在加载 API 密钥……",
|
||||
"xpack.security.management.apiKeys.table.apiKeysTitle": "API 密钥",
|
||||
"xpack.security.management.apiKeys.table.creationDateColumnName": "已创建",
|
||||
"xpack.security.management.apiKeys.table.emptyPromptAdminTitle": "无 API 密钥",
|
||||
"xpack.security.management.apiKeys.table.emptyPromptConsoleButtonMessage": "前往 Console",
|
||||
"xpack.security.management.apiKeys.table.emptyPromptDescription": "您可以从 Console 创建 {link}。",
|
||||
"xpack.security.management.apiKeys.table.emptyPromptDocsLinkMessage": "API 密钥",
|
||||
"xpack.security.management.apiKeys.table.emptyPromptNonAdminTitle": "您未有任何 API 密钥",
|
||||
"xpack.security.management.apiKeys.table.expirationDateColumnName": "过期",
|
||||
"xpack.security.management.apiKeys.table.expirationDateNeverMessage": "永不",
|
||||
"xpack.security.management.apiKeys.table.fetchingApiKeysErrorMessage": "检查权限时出错:{message}",
|
||||
"xpack.security.management.apiKeys.table.invalidateApiKeyButton": "作废 {count, plural, other {API 密钥}}",
|
||||
"xpack.security.management.apiKeys.table.loadingApiKeysDescription": "正在加载 API 密钥……",
|
||||
"xpack.security.management.apiKeys.table.loadingApiKeysErrorTitle": "加载 API 密钥时出错",
|
||||
"xpack.security.management.apiKeys.table.nameColumnName": "名称",
|
||||
"xpack.security.management.apiKeys.table.realmColumnName": "Realm",
|
||||
"xpack.security.management.apiKeys.table.realmFilterLabel": "Realm",
|
||||
"xpack.security.management.apiKeys.table.reloadApiKeysButton": "重新加载",
|
||||
"xpack.security.management.apiKeys.table.statusColumnName": "状态",
|
||||
"xpack.security.management.apiKeys.table.userFilterLabel": "用户",
|
||||
"xpack.security.management.apiKeys.table.userNameColumnName": "用户",
|
||||
|
|
|
@ -25,5 +25,27 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /internal/security/api_key', () => {
|
||||
it('should allow an API Key to be created', async () => {
|
||||
await supertest
|
||||
.post('/internal/security/api_key')
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.send({
|
||||
name: 'test_api_key',
|
||||
expiration: '12d',
|
||||
role_descriptors: {
|
||||
role_1: {
|
||||
cluster: ['monitor'],
|
||||
},
|
||||
},
|
||||
})
|
||||
.expect(200)
|
||||
.then((response: Record<string, any>) => {
|
||||
const { name } = response.body;
|
||||
expect(name).to.eql('test_api_key');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -5,7 +5,6 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import expect from '@kbn/expect';
|
||||
import { FtrProviderContext } from '../../ftr_provider_context';
|
||||
|
||||
export default ({ getPageObjects, getService }: FtrProviderContext) => {
|
||||
|
@ -13,6 +12,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
|
|||
const log = getService('log');
|
||||
const security = getService('security');
|
||||
const testSubjects = getService('testSubjects');
|
||||
const find = getService('find');
|
||||
|
||||
describe('Home page', function () {
|
||||
before(async () => {
|
||||
|
@ -31,17 +31,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
|
|||
|
||||
it('Loads the app', async () => {
|
||||
await security.testUser.setRoles(['test_api_keys']);
|
||||
log.debug('Checking for section header');
|
||||
const headers = await testSubjects.findAll('noApiKeysHeader');
|
||||
if (headers.length > 0) {
|
||||
expect(await headers[0].getVisibleText()).to.be('No API keys');
|
||||
const goToConsoleButton = await pageObjects.apiKeys.getGoToConsoleButton();
|
||||
expect(await goToConsoleButton.isDisplayed()).to.be(true);
|
||||
} else {
|
||||
// page may already contain EiTable with data, then check API Key Admin text
|
||||
const description = await pageObjects.apiKeys.getApiKeyAdminDesc();
|
||||
expect(description).to.be('You are an API Key administrator.');
|
||||
}
|
||||
log.debug('Checking for create API key call to action');
|
||||
await find.existsByLinkText('Create API key');
|
||||
});
|
||||
});
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue