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:
Thom Heymann 2021-04-13 12:21:11 +01:00 committed by GitHub
parent 3a7155eaa1
commit 69f013e2fb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 155 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 KiB

View file

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

View file

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

View file

@ -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',
() => (

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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}
/>
);
};

View file

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

View file

@ -10,5 +10,6 @@ export const apiKeysAPIClientMock = {
checkPrivileges: jest.fn(),
getApiKeys: jest.fn(),
invalidateApiKeys: jest.fn(),
createApiKey: jest.fn(),
}),
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -5,4 +5,4 @@
* 2.0.
*/
export { InvalidateProvider } from './invalidate_provider';
export { InvalidateProvider, InvalidateApiKeys } from './invalidate_provider';

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View 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));
}
})
);
}

View file

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

View file

@ -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": "ユーザー",

View file

@ -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": "用户",

View file

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

View file

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