Add API Keys app to Management > Security. (#45740) (#48175)

* Add API Keys app to Management > Security.
- For admins, list all API Keys created by the user: Name, Date Created, Expiration Date, Status, User, and Realm.
- For non-admins, list own API keys: Name, Date Created, Expiration Date, and Status.
- Surface admin status above table.
- Ability to search by Name and Revoke (invalidate) API keys, and filter by User and Realm.
- Surface feedback when API keys are disabled on Elasticsearch or when user lacks required permissions.
* Add `SectionLoading` component to `es_ui_shared` plugin.
This commit is contained in:
CJ Cenizal 2019-10-14 20:17:49 -07:00 committed by GitHub
parent bad9c8b762
commit f2e91f6c3a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 1349 additions and 1 deletions

View file

@ -0,0 +1,20 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export { SectionLoading } from './section_loading';

View file

@ -0,0 +1,60 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import {
EuiEmptyPrompt,
EuiLoadingSpinner,
EuiText,
EuiFlexGroup,
EuiFlexItem,
EuiTextColor,
} from '@elastic/eui';
interface Props {
inline?: boolean;
children: React.ReactNode;
[key: string]: any;
}
export const SectionLoading: React.FunctionComponent<Props> = ({ inline, children, ...rest }) => {
if (inline) {
return (
<EuiFlexGroup justifyContent="flexStart" alignItems="center" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size="m" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText {...rest}>
<EuiTextColor color="subdued">{children}</EuiTextColor>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
);
}
return (
<EuiEmptyPrompt
title={<EuiLoadingSpinner size="xl" />}
body={<EuiText color="subdued">{children}</EuiText>}
data-test-subj="sectionLoading"
/>
);
};

View file

@ -8,3 +8,4 @@ export const GLOBAL_RESOURCE = '*';
export const IGNORED_TYPES = ['space'];
export const APPLICATION_PREFIX = 'kibana-';
export const RESERVED_PRIVILEGES_APPLICATION_WILDCARD = 'kibana-*';
export const INTERNAL_API_BASE_PATH = '/internal/security';

View file

@ -0,0 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export interface ApiKey {
id: string;
name: string;
username: string;
realm: string;
creation: number;
expiration: number;
invalidated: boolean;
}
export interface ApiKeyToInvalidate {
id: string;
name: string;
}

View file

@ -8,6 +8,7 @@ export { Role, RoleIndexPrivilege, RoleKibanaPrivilege } from './role';
export { FeaturesPrivileges } from './features_privileges';
export { RawKibanaPrivileges, RawKibanaFeaturePrivileges } from './raw_kibana_privileges';
export { KibanaPrivileges } from './kibana_privileges';
export { ApiKey } from './api_key';
export { User, EditUser, getUserDisplayName } from '../../../../../plugins/security/common/model';
export {
AuthenticatedUser,

View file

@ -8,6 +8,7 @@ import { resolve } from 'path';
import { get, has } from 'lodash';
import { initAuthenticateApi } from './server/routes/api/v1/authenticate';
import { initUsersApi } from './server/routes/api/v1/users';
import { initApiKeysApi } from './server/routes/api/v1/api_keys';
import { initExternalRolesApi } from './server/routes/api/external/roles';
import { initPrivilegesApi } from './server/routes/api/external/privileges';
import { initIndicesApi } from './server/routes/api/v1/indices';
@ -214,6 +215,7 @@ export const security = (kibana) => new kibana.Plugin({
initAPIAuthorization(server, authorization);
initAppAuthorization(server, xpackMainPlugin, authorization);
initUsersApi(securityPlugin, server);
initApiKeysApi(server);
initExternalRolesApi(server);
initIndicesApi(server);
initPrivilegesApi(server);

View file

@ -0,0 +1,48 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { kfetch } from 'ui/kfetch';
import { ApiKey, ApiKeyToInvalidate } from '../../common/model/api_key';
import { INTERNAL_API_BASE_PATH } from '../../common/constants';
interface CheckPrivilegesResponse {
areApiKeysEnabled: boolean;
isAdmin: boolean;
}
interface InvalidateApiKeysResponse {
itemsInvalidated: ApiKeyToInvalidate[];
errors: any[];
}
interface GetApiKeysResponse {
apiKeys: ApiKey[];
}
const apiKeysUrl = `${INTERNAL_API_BASE_PATH}/api_key`;
export class ApiKeysApi {
public static async checkPrivileges(): Promise<CheckPrivilegesResponse> {
return kfetch({ pathname: `${apiKeysUrl}/privileges` });
}
public static async getApiKeys(isAdmin: boolean = false): Promise<GetApiKeysResponse> {
const query = {
isAdmin,
};
return kfetch({ pathname: apiKeysUrl, query });
}
public static async invalidateApiKeys(
apiKeys: ApiKeyToInvalidate[],
isAdmin: boolean = false
): Promise<InvalidateApiKeysResponse> {
const pathname = `${apiKeysUrl}/invalidate`;
const body = JSON.stringify({ apiKeys, isAdmin });
return kfetch({ pathname, method: 'POST', body });
}
}

View file

@ -0,0 +1,3 @@
<kbn-management-app section="security/api_keys">
<div id="apiKeysGridReactRoot" />
</kbn-management-app>

View file

@ -0,0 +1,33 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import routes from 'ui/routes';
import template from './api_keys.html';
import { API_KEYS_PATH } from '../management_urls';
import { getApiKeysBreadcrumbs } from '../breadcrumbs';
import { I18nContext } from 'ui/i18n';
import { ApiKeysGridPage } from './components';
routes.when(API_KEYS_PATH, {
template,
k7Breadcrumbs: getApiKeysBreadcrumbs,
controller($scope) {
$scope.$$postDigest(() => {
const domNode = document.getElementById('apiKeysGridReactRoot');
render(
<I18nContext>
<ApiKeysGridPage />
</I18nContext>, domNode);
// unmount react on controller destroy
$scope.$on('$destroy', () => {
unmountComponentAtNode(domNode);
});
});
},
});

View file

@ -0,0 +1,528 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Component } from 'react';
import {
EuiBadge,
EuiButton,
EuiButtonIcon,
EuiCallOut,
EuiFlexGroup,
EuiFlexItem,
EuiInMemoryTable,
EuiPageContent,
EuiPageContentBody,
EuiPageContentHeader,
EuiPageContentHeaderSection,
EuiSpacer,
EuiText,
EuiTitle,
EuiToolTip,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import moment from 'moment-timezone';
import _ from 'lodash';
import { toastNotifications } from 'ui/notify';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { SectionLoading } from '../../../../../../../../../src/plugins/es_ui_shared/public/components/section_loading';
import { ApiKey, ApiKeyToInvalidate } from '../../../../../common/model/api_key';
import { ApiKeysApi } from '../../../../lib/api_keys_api';
import { PermissionDenied } from './permission_denied';
import { EmptyPrompt } from './empty_prompt';
import { NotEnabled } from './not_enabled';
import { InvalidateProvider } from './invalidate_provider';
interface State {
isLoadingApp: boolean;
isLoadingTable: boolean;
isAdmin: boolean;
areApiKeysEnabled: boolean;
apiKeys: ApiKey[];
selectedItems: ApiKey[];
permissionDenied: boolean;
error: any;
}
const DATE_FORMAT = 'MMMM Do YYYY HH:mm:ss';
export class ApiKeysGridPage extends Component<any, State> {
constructor(props: any) {
super(props);
this.state = {
isLoadingApp: true,
isLoadingTable: false,
isAdmin: false,
areApiKeysEnabled: false,
apiKeys: [],
permissionDenied: false,
selectedItems: [],
error: undefined,
};
}
public componentDidMount() {
this.checkPrivileges();
}
public render() {
const {
permissionDenied,
isLoadingApp,
isLoadingTable,
areApiKeysEnabled,
isAdmin,
error,
apiKeys,
} = this.state;
if (permissionDenied) {
return <PermissionDenied />;
}
if (isLoadingApp) {
return (
<EuiPageContent>
<SectionLoading>
<FormattedMessage
id="xpack.security.management.apiKeys.table.loadingApiKeysDescription"
defaultMessage="Loading API keys…"
/>
</SectionLoading>
</EuiPageContent>
);
}
if (error) {
const {
body: { error: errorTitle, message, statusCode },
} = error;
return (
<EuiPageContent>
<EuiCallOut
title={
<FormattedMessage
id="xpack.security.management.apiKeys.table.loadingApiKeysErrorTitle"
defaultMessage="Error loading API keys"
/>
}
color="danger"
iconType="alert"
>
{statusCode}: {errorTitle} - {message}
</EuiCallOut>
</EuiPageContent>
);
}
if (!areApiKeysEnabled) {
return (
<EuiPageContent>
<NotEnabled />
</EuiPageContent>
);
}
if (!isLoadingTable && apiKeys && apiKeys.length === 0) {
return (
<EuiPageContent>
<EmptyPrompt isAdmin={isAdmin} />
</EuiPageContent>
);
}
const description = (
<EuiText color="subdued" size="s">
<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>
);
return (
<EuiPageContent>
<EuiPageContentHeader>
<EuiPageContentHeaderSection>
<EuiTitle>
<h2>
<FormattedMessage
id="xpack.security.management.apiKeys.table.apiKeysTitle"
defaultMessage="API Keys"
/>
</h2>
</EuiTitle>
{description}
</EuiPageContentHeaderSection>
</EuiPageContentHeader>
<EuiPageContentBody>{this.renderTable()}</EuiPageContentBody>
</EuiPageContent>
);
}
private renderTable = () => {
const { apiKeys, selectedItems, isLoadingTable, isAdmin } = this.state;
const message = isLoadingTable ? (
<FormattedMessage
id="xpack.security.management.apiKeys.table.apiKeysTableLoadingMessage"
defaultMessage="Loading API keys…"
/>
) : (
undefined
);
const sorting = {
sort: {
field: 'expiration',
direction: 'asc',
},
};
const pagination = {
initialPageSize: 20,
pageSizeOptions: [10, 20, 50],
};
const selection = {
onSelectionChange: (newSelectedItems: ApiKey[]) => {
this.setState({
selectedItems: newSelectedItems,
});
},
};
const search = {
toolsLeft: selectedItems.length ? (
<InvalidateProvider isAdmin={isAdmin}>
{invalidateApiKeyPrompt => {
return (
<EuiButton
onClick={() =>
invalidateApiKeyPrompt(
selectedItems.map(({ name, id }) => ({ name, id })),
this.onApiKeysInvalidated
)
}
color="danger"
data-test-subj="bulkInvalidateActionButton"
>
<FormattedMessage
id="xpack.security.management.apiKeys.table.invalidateApiKeyButton"
defaultMessage="Invalidate {count, plural, one {API key} other {API keys}}"
values={{
count: selectedItems.length,
}}
/>
</EuiButton>
);
}}
</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,
},
filters: isAdmin
? [
{
type: 'field_value_selection',
field: 'username',
name: i18n.translate('xpack.security.management.apiKeys.table.userFilterLabel', {
defaultMessage: 'User',
}),
multiSelect: false,
options: Object.keys(
apiKeys.reduce((apiKeysMap: any, apiKey) => {
apiKeysMap[apiKey.username] = true;
return apiKeysMap;
}, {})
).map(username => {
return {
value: username,
view: username,
};
}),
},
{
type: 'field_value_selection',
field: 'realm',
name: i18n.translate('xpack.security.management.apiKeys.table.realmFilterLabel', {
defaultMessage: 'Realm',
}),
multiSelect: false,
options: Object.keys(
apiKeys.reduce((apiKeysMap: any, apiKey) => {
apiKeysMap[apiKey.realm] = true;
return apiKeysMap;
}, {})
).map(realm => {
return {
value: realm,
view: realm,
};
}),
},
]
: undefined,
};
return (
<>
{isAdmin ? (
<>
<EuiCallOut
title={
<FormattedMessage
id="xpack.security.management.apiKeys.table.adminText"
defaultMessage="You are an API Key administrator."
/>
}
color="success"
iconType="user"
size="s"
/>
<EuiSpacer size="m" />
</>
) : (
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',
};
}}
/>
}
</>
);
};
private getColumnConfig = () => {
const { isAdmin } = this.state;
let config = [
{
field: 'name',
name: i18n.translate('xpack.security.management.apiKeys.table.nameColumnName', {
defaultMessage: 'Name',
}),
sortable: true,
},
];
if (isAdmin) {
config = config.concat([
{
field: 'username',
name: i18n.translate('xpack.security.management.apiKeys.table.userNameColumnName', {
defaultMessage: 'User',
}),
sortable: true,
},
{
field: 'realm',
name: i18n.translate('xpack.security.management.apiKeys.table.realmColumnName', {
defaultMessage: 'Realm',
}),
sortable: true,
},
]);
}
config = config.concat([
{
field: 'creation',
name: i18n.translate('xpack.security.management.apiKeys.table.creationDateColumnName', {
defaultMessage: 'Created',
}),
sortable: true,
// @ts-ignore
render: (creationDateMs: number) => moment(creationDateMs).format(DATE_FORMAT),
},
{
field: 'expiration',
name: i18n.translate('xpack.security.management.apiKeys.table.expirationDateColumnName', {
defaultMessage: 'Expires',
}),
sortable: true,
// @ts-ignore
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);
},
},
{
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>;
}
return <EuiBadge color="secondary">Active</EuiBadge>;
},
},
{
name: i18n.translate('xpack.security.management.apiKeys.table.actionsColumnName', {
defaultMessage: 'Actions',
}),
actions: [
{
render: ({ name, id }: any) => {
return (
<EuiFlexGroup gutterSize="s">
<EuiFlexItem>
<InvalidateProvider isAdmin={isAdmin}>
{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>
);
},
},
],
},
]);
return config;
};
private onApiKeysInvalidated = (apiKeysInvalidated: ApiKeyToInvalidate[]): void => {
if (apiKeysInvalidated.length) {
this.reloadApiKeys();
}
};
private async checkPrivileges() {
try {
const { isAdmin, areApiKeysEnabled } = await ApiKeysApi.checkPrivileges();
this.setState({ isAdmin, areApiKeysEnabled });
if (areApiKeysEnabled) {
this.initiallyLoadApiKeys();
} else {
// We're done loading and will just show the "Disabled" error.
this.setState({ isLoadingApp: false });
}
} catch (e) {
if (_.get(e, 'body.statusCode') === 403) {
this.setState({ permissionDenied: true, isLoadingApp: false });
} else {
toastNotifications.addDanger(
this.props.i18n.translate(
'xpack.security.management.apiKeys.table.fetchingApiKeysErrorMessage',
{
defaultMessage: 'Error checking privileges: {message}',
values: { message: _.get(e, 'body.message', '') },
}
)
);
}
}
}
private initiallyLoadApiKeys = () => {
this.setState({ isLoadingApp: true, isLoadingTable: false });
this.loadApiKeys();
};
private reloadApiKeys = () => {
this.setState({ apiKeys: [], isLoadingApp: false, isLoadingTable: true });
this.loadApiKeys();
};
private loadApiKeys = async () => {
try {
const { isAdmin } = this.state;
const { apiKeys } = await ApiKeysApi.getApiKeys(isAdmin);
this.setState({ apiKeys });
} catch (e) {
this.setState({ error: e });
}
this.setState({ isLoadingApp: false, isLoadingTable: false });
};
}

View file

@ -0,0 +1,64 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment } from 'react';
import { EuiEmptyPrompt, EuiButton, EuiLink } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { documentationLinks } from '../../services/documentation_links';
interface Props {
isAdmin: boolean;
}
export const EmptyPrompt: React.FunctionComponent<Props> = ({ isAdmin }) => (
<EuiEmptyPrompt
iconType="managementApp"
title={
<h1>
{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={documentationLinks.getCreateApiKeyDocUrl()} target="_blank">
<FormattedMessage
id="xpack.security.management.apiKeys.table.emptyPromptDocsLinkMessage"
defaultMessage="API key"
/>
</EuiLink>
),
}}
/>
</p>
</Fragment>
}
actions={
<EuiButton type="primary" href="#/dev_tools">
<FormattedMessage
id="xpack.security.management.apiKeys.table.emptyPromptConsoleButtonMessage"
defaultMessage="Go to Console"
/>
</EuiButton>
}
data-test-subj="emptyPrompt"
/>
);

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { EmptyPrompt } from './empty_prompt';

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { ApiKeysGridPage } from './api_keys_grid_page';

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { InvalidateProvider } from './invalidate_provider';

View file

@ -0,0 +1,182 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment, useRef, useState } from 'react';
import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui';
import { toastNotifications } from 'ui/notify';
import { i18n } from '@kbn/i18n';
import { ApiKeyToInvalidate } from '../../../../../../common/model/api_key';
import { ApiKeysApi } from '../../../../../lib/api_keys_api';
interface Props {
isAdmin: boolean;
children: (invalidateApiKeys: InvalidateApiKeys) => React.ReactElement;
}
export type InvalidateApiKeys = (
apiKeys: ApiKeyToInvalidate[],
onSuccess?: OnSuccessCallback
) => void;
type OnSuccessCallback = (apiKeysInvalidated: ApiKeyToInvalidate[]) => void;
export const InvalidateProvider: React.FunctionComponent<Props> = ({ isAdmin, children }) => {
const [apiKeys, setApiKeys] = useState<ApiKeyToInvalidate[]>([]);
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
const onSuccessCallback = useRef<OnSuccessCallback | null>(null);
const invalidateApiKeyPrompt: InvalidateApiKeys = (keys, onSuccess = () => undefined) => {
if (!keys || !keys.length) {
throw new Error('No API key IDs specified for invalidation');
}
setIsModalOpen(true);
setApiKeys(keys);
onSuccessCallback.current = onSuccess;
};
const closeModal = () => {
setIsModalOpen(false);
setApiKeys([]);
};
const invalidateApiKey = async () => {
let result;
let error;
let errors;
try {
result = await ApiKeysApi.invalidateApiKeys(apiKeys, isAdmin);
} catch (e) {
error = e;
}
closeModal();
if (result) {
const { itemsInvalidated } = result;
({ errors } = result);
// Surface success notifications
if (itemsInvalidated && itemsInvalidated.length) {
const hasMultipleSuccesses = itemsInvalidated.length > 1;
const successMessage = hasMultipleSuccesses
? i18n.translate(
'xpack.security.management.apiKeys.invalidateApiKey.successMultipleNotificationTitle',
{
defaultMessage: 'Invalidated {count} API keys',
values: { count: itemsInvalidated.length },
}
)
: i18n.translate(
'xpack.security.management.apiKeys.invalidateApiKey.successSingleNotificationTitle',
{
defaultMessage: "Invalidated API key '{name}'",
values: { name: itemsInvalidated[0].name },
}
);
toastNotifications.addSuccess(successMessage);
if (onSuccessCallback.current) {
onSuccessCallback.current([...itemsInvalidated]);
}
}
}
// Surface error notifications
// `error` is generic server error
// `errors` are specific errors with removing particular API keys
if (error || (errors && errors.length)) {
const hasMultipleErrors = (errors && errors.length > 1) || (error && apiKeys.length > 1);
const errorMessage = hasMultipleErrors
? i18n.translate(
'xpack.security.management.apiKeys.invalidateApiKey.errorMultipleNotificationTitle',
{
defaultMessage: 'Error deleting {count} apiKeys',
values: {
count: (errors && errors.length) || apiKeys.length,
},
}
)
: i18n.translate(
'xpack.security.management.apiKeys.invalidateApiKey.errorSingleNotificationTitle',
{
defaultMessage: "Error deleting API key '{name}'",
values: { name: (errors && errors[0].name) || apiKeys[0].name },
}
);
toastNotifications.addDanger(errorMessage);
}
};
const renderModal = () => {
if (!isModalOpen) {
return null;
}
const isSingle = apiKeys.length === 1;
return (
<EuiOverlayMask>
<EuiConfirmModal
title={
isSingle
? i18n.translate(
'xpack.security.management.apiKeys.invalidateApiKey.confirmModal.invalidateSingleTitle',
{
defaultMessage: "Invalidate API key '{name}'?",
values: { name: apiKeys[0].name },
}
)
: i18n.translate(
'xpack.security.management.apiKeys.invalidateApiKey.confirmModal.invalidateMultipleTitle',
{
defaultMessage: 'Invalidate {count} API keys?',
values: { count: apiKeys.length },
}
)
}
onCancel={closeModal}
onConfirm={invalidateApiKey}
cancelButtonText={i18n.translate(
'xpack.security.management.apiKeys.invalidateApiKey.confirmModal.cancelButtonLabel',
{ defaultMessage: 'Cancel' }
)}
confirmButtonText={i18n.translate(
'xpack.security.management.apiKeys.invalidateApiKey.confirmModal.confirmButtonLabel',
{
defaultMessage: 'Invalidate {count, plural, one {API key} other {API keys}}',
values: { count: apiKeys.length },
}
)}
buttonColor="danger"
data-test-subj="invalidateApiKeyConfirmationModal"
>
{!isSingle ? (
<Fragment>
<p>
{i18n.translate(
'xpack.security.management.apiKeys.invalidateApiKey.confirmModal.invalidateMultipleListDescription',
{ defaultMessage: 'You are about to invalidate these API keys:' }
)}
</p>
<ul>
{apiKeys.map(({ name, id }) => (
<li key={id}>{name}</li>
))}
</ul>
</Fragment>
) : null}
</EuiConfirmModal>
</EuiOverlayMask>
);
};
return (
<Fragment>
{children(invalidateApiKeyPrompt)}
{renderModal()}
</Fragment>
);
};

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { NotEnabled } from './not_enabled';

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;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { EuiCallOut, EuiLink } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { documentationLinks } from '../../services/documentation_links';
export const NotEnabled: React.FunctionComponent = () => (
<EuiCallOut
title={
<FormattedMessage
id="xpack.security.management.apiKeys.table.apiKeysDisabledErrorTitle"
defaultMessage="API keys not enabled in Elasticsearch"
/>
}
color="danger"
iconType="alert"
>
<FormattedMessage
id="xpack.security.management.apiKeys.table.apiKeysDisabledErrorDescription"
defaultMessage="Contact your system administrator and refer to the {link} to enable API keys."
values={{
link: (
<EuiLink href={documentationLinks.getApiKeyServiceSettingsDocUrl()} target="_blank">
<FormattedMessage
id="xpack.security.management.apiKeys.table.apiKeysDisabledErrorLinkText"
defaultMessage="docs"
/>
</EuiLink>
),
}}
/>
</EuiCallOut>
);

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { PermissionDenied } from './permission_denied';

View file

@ -0,0 +1,34 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiEmptyPrompt, EuiFlexGroup, EuiPageContent } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import React from 'react';
export const PermissionDenied = () => (
<EuiFlexGroup gutterSize="none">
<EuiPageContent horizontalPosition="center">
<EuiEmptyPrompt
iconType="securityApp"
title={
<h2>
<FormattedMessage
id="xpack.security.management.apiKeys.deniedPermissionTitle"
defaultMessage="You need permission to manage API keys"
/>
</h2>
}
body={
<p data-test-subj="permissionDeniedMessage">
<FormattedMessage
id="xpack.security.management.apiKeys.noPermissionToManageRolesDescription"
defaultMessage="Contact your system administrator."
/>
</p>
}
/>
</EuiPageContent>
</EuiFlexGroup>
);

View file

@ -0,0 +1,21 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } from 'ui/documentation_links';
class DocumentationLinksService {
private esDocBasePath = `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}/`;
public getApiKeyServiceSettingsDocUrl(): string {
return `${this.esDocBasePath}security-settings.html#api-key-service-settings`;
}
public getCreateApiKeyDocUrl(): string {
return `${this.esDocBasePath}security-api-create-api-key.html`;
}
}
export const documentationLinks = new DocumentationLinksService();

View file

@ -74,3 +74,15 @@ export function getCreateRoleBreadcrumbs() {
},
];
}
export function getApiKeysBreadcrumbs() {
return [
MANAGEMENT_BREADCRUMB,
{
text: i18n.translate('xpack.security.apiKeys.breadcrumb', {
defaultMessage: 'API Keys',
}),
href: '#/management/security/api_keys',
},
];
}

View file

@ -8,12 +8,13 @@ import 'plugins/security/views/management/change_password_form/change_password_f
import 'plugins/security/views/management/password_form/password_form';
import 'plugins/security/views/management/users_grid/users';
import 'plugins/security/views/management/roles_grid/roles';
import 'plugins/security/views/management/api_keys_grid/api_keys';
import 'plugins/security/views/management/edit_user/edit_user';
import 'plugins/security/views/management/edit_role/index';
import routes from 'ui/routes';
import { xpackInfo } from 'plugins/xpack_main/services/xpack_info';
import '../../services/shield_user';
import { ROLES_PATH, USERS_PATH } from './management_urls';
import { ROLES_PATH, USERS_PATH, API_KEYS_PATH } from './management_urls';
import { management } from 'ui/management';
import { i18n } from '@kbn/i18n';
@ -76,6 +77,18 @@ routes.defaults(/^\/management\/security(\/|$)/, {
url: `#${ROLES_PATH}`,
});
}
if (!security.hasItem('apiKeys')) {
security.register('apiKeys', {
name: 'securityApiKeysLink',
order: 30,
display: i18n.translate(
'xpack.security.management.apiKeysTitle', {
defaultMessage: 'API Keys',
}),
url: `#${API_KEYS_PATH}`,
});
}
}
if (!showSecurityLinks) {

View file

@ -11,3 +11,4 @@ export const EDIT_ROLES_PATH = `${ROLES_PATH}/edit`;
export const CLONE_ROLES_PATH = `${ROLES_PATH}/clone`;
export const USERS_PATH = `${SECURITY_PATH}/users`;
export const EDIT_USERS_PATH = `${USERS_PATH}/edit`;
export const API_KEYS_PATH = `${SECURITY_PATH}/api_keys`;

View file

@ -0,0 +1,45 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import Joi from 'joi';
import { wrapError } from '../../../../../../../../plugins/security/server';
import { INTERNAL_API_BASE_PATH } from '../../../../../common/constants';
export function initGetApiKeysApi(server, callWithRequest, routePreCheckLicenseFn) {
server.route({
method: 'GET',
path: `${INTERNAL_API_BASE_PATH}/api_key`,
async handler(request) {
try {
const { isAdmin } = request.query;
const result = await callWithRequest(
request,
'shield.getAPIKeys',
{
owner: !isAdmin
}
);
const validKeys = result.api_keys.filter(({ invalidated }) => !invalidated);
return {
apiKeys: validKeys,
};
} catch (error) {
return wrapError(error);
}
},
config: {
pre: [routePreCheckLicenseFn],
validate: {
query: Joi.object().keys({
isAdmin: Joi.bool().required(),
}).required(),
},
}
});
}

View file

@ -0,0 +1,23 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { getClient } from '../../../../../../../server/lib/get_client_shield';
import { routePreCheckLicense } from '../../../../lib/route_pre_check_license';
import { initCheckPrivilegesApi } from './privileges';
import { initGetApiKeysApi } from './get';
import { initInvalidateApiKeysApi } from './invalidate';
export function initApiKeysApi(server) {
const callWithRequest = getClient(server).callWithRequest;
const routePreCheckLicenseFn = routePreCheckLicense(server);
const { authorization } = server.plugins.security;
const { application } = authorization;
initCheckPrivilegesApi(server, callWithRequest, routePreCheckLicenseFn, application);
initGetApiKeysApi(server, callWithRequest, routePreCheckLicenseFn, application);
initInvalidateApiKeysApi(server, callWithRequest, routePreCheckLicenseFn, application);
}

View file

@ -0,0 +1,70 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import Joi from 'joi';
import { wrapError } from '../../../../../../../../plugins/security/server';
import { INTERNAL_API_BASE_PATH } from '../../../../../common/constants';
export function initInvalidateApiKeysApi(server, callWithRequest, routePreCheckLicenseFn) {
server.route({
method: 'POST',
path: `${INTERNAL_API_BASE_PATH}/api_key/invalidate`,
async handler(request) {
try {
const { apiKeys, isAdmin } = request.payload;
const itemsInvalidated = [];
const errors = [];
// Send the request to invalidate the API key and return an error if it could not be deleted.
const sendRequestToInvalidateApiKey = async (id) => {
try {
const body = { id };
if (!isAdmin) {
body.owner = true;
}
await callWithRequest(request, 'shield.invalidateAPIKey', { body });
return null;
} catch (error) {
return wrapError(error);
}
};
const invalidateApiKey = async ({ id, name }) => {
const error = await sendRequestToInvalidateApiKey(id);
if (error) {
errors.push({ id, name, error });
} else {
itemsInvalidated.push({ id, name });
}
};
// Invalidate all API keys in parallel.
await Promise.all(apiKeys.map((key) => invalidateApiKey(key)));
return {
itemsInvalidated,
errors,
};
} catch (error) {
return wrapError(error);
}
},
config: {
pre: [routePreCheckLicenseFn],
validate: {
payload: Joi.object({
apiKeys: Joi.array().items(Joi.object({
id: Joi.string().required(),
name: Joi.string().required(),
})).required(),
isAdmin: Joi.bool().required(),
})
},
}
});
}

View file

@ -0,0 +1,75 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { wrapError } from '../../../../../../../../plugins/security/server';
import { INTERNAL_API_BASE_PATH } from '../../../../../common/constants';
export function initCheckPrivilegesApi(server, callWithRequest, routePreCheckLicenseFn) {
server.route({
method: 'GET',
path: `${INTERNAL_API_BASE_PATH}/api_key/privileges`,
async handler(request) {
try {
const result = await Promise.all([
callWithRequest(
request,
'shield.hasPrivileges',
{
body: {
cluster: [
'manage_security',
'manage_api_key',
],
},
}
),
new Promise(async (resolve, reject) => {
try {
const result = await callWithRequest(
request,
'shield.getAPIKeys',
{
owner: true
}
);
// If the API returns a truthy result that means it's enabled.
resolve({ areApiKeysEnabled: !!result });
} catch (e) {
// This is a brittle dependency upon message. Tracked by https://github.com/elastic/elasticsearch/issues/47759.
if (e.message.includes('api keys are not enabled')) {
return resolve({ areApiKeysEnabled: false });
}
// It's a real error, so rethrow it.
reject(e);
}
}),
]);
const [{
cluster: {
manage_security: manageSecurity,
manage_api_key: manageApiKey,
}
}, {
areApiKeysEnabled,
}] = result;
const isAdmin = manageSecurity || manageApiKey;
return {
areApiKeysEnabled,
isAdmin,
};
} catch (error) {
return wrapError(error);
}
},
config: {
pre: [routePreCheckLicenseFn]
}
});
}

View file

@ -502,6 +502,25 @@
]
});
/**
* Gets API keys in Elasticsearch
* @param {boolean} owner A boolean flag that can be used to query API keys owned by the currently authenticated user.
* Defaults to false. The realm_name or username parameters cannot be specified when this parameter is set to true as
* they are assumed to be the currently authenticated ones.
*/
shield.getAPIKeys = ca({
method: 'GET',
urls: [{
fmt: `/_security/api_key?owner=<%=owner%>`,
req: {
owner: {
type: 'boolean',
required: true
}
}
}]
});
/**
* Creates an API key in Elasticsearch for the current user.
*