diff --git a/src/plugins/es_ui_shared/public/components/section_loading/index.ts b/src/plugins/es_ui_shared/public/components/section_loading/index.ts new file mode 100644 index 0000000000000..6e10fe9a89cd1 --- /dev/null +++ b/src/plugins/es_ui_shared/public/components/section_loading/index.ts @@ -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'; diff --git a/src/plugins/es_ui_shared/public/components/section_loading/section_loading.tsx b/src/plugins/es_ui_shared/public/components/section_loading/section_loading.tsx new file mode 100644 index 0000000000000..41495d605d768 --- /dev/null +++ b/src/plugins/es_ui_shared/public/components/section_loading/section_loading.tsx @@ -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 = ({ inline, children, ...rest }) => { + if (inline) { + return ( + + + + + + + {children} + + + + ); + } + + return ( + } + body={{children}} + data-test-subj="sectionLoading" + /> + ); +}; diff --git a/x-pack/legacy/plugins/security/common/constants.ts b/x-pack/legacy/plugins/security/common/constants.ts index 2a255ecd335e5..2ec429b4d9c4c 100644 --- a/x-pack/legacy/plugins/security/common/constants.ts +++ b/x-pack/legacy/plugins/security/common/constants.ts @@ -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'; diff --git a/x-pack/legacy/plugins/security/common/model/api_key.ts b/x-pack/legacy/plugins/security/common/model/api_key.ts new file mode 100644 index 0000000000000..acdf999da4a0f --- /dev/null +++ b/x-pack/legacy/plugins/security/common/model/api_key.ts @@ -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; +} diff --git a/x-pack/legacy/plugins/security/common/model/index.ts b/x-pack/legacy/plugins/security/common/model/index.ts index 31757543ac3f8..19243c25fef7e 100644 --- a/x-pack/legacy/plugins/security/common/model/index.ts +++ b/x-pack/legacy/plugins/security/common/model/index.ts @@ -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, diff --git a/x-pack/legacy/plugins/security/index.js b/x-pack/legacy/plugins/security/index.js index 980af19cc8362..f9e82f575ce2e 100644 --- a/x-pack/legacy/plugins/security/index.js +++ b/x-pack/legacy/plugins/security/index.js @@ -7,6 +7,7 @@ import { resolve } from 'path'; 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'; @@ -195,6 +196,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); diff --git a/x-pack/legacy/plugins/security/public/lib/api_keys_api.ts b/x-pack/legacy/plugins/security/public/lib/api_keys_api.ts new file mode 100644 index 0000000000000..c6dcef392af98 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/lib/api_keys_api.ts @@ -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 { + return kfetch({ pathname: `${apiKeysUrl}/privileges` }); + } + + public static async getApiKeys(isAdmin: boolean = false): Promise { + const query = { + isAdmin, + }; + + return kfetch({ pathname: apiKeysUrl, query }); + } + + public static async invalidateApiKeys( + apiKeys: ApiKeyToInvalidate[], + isAdmin: boolean = false + ): Promise { + const pathname = `${apiKeysUrl}/invalidate`; + const body = JSON.stringify({ apiKeys, isAdmin }); + return kfetch({ pathname, method: 'POST', body }); + } +} diff --git a/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/api_keys.html b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/api_keys.html new file mode 100644 index 0000000000000..e46c6f72b5d20 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/api_keys.html @@ -0,0 +1,3 @@ + +
+ diff --git a/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/api_keys.js b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/api_keys.js new file mode 100644 index 0000000000000..f143df8c9742f --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/api_keys.js @@ -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( + + + , domNode); + + // unmount react on controller destroy + $scope.$on('$destroy', () => { + unmountComponentAtNode(domNode); + }); + }); + }, +}); diff --git a/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/api_keys_grid_page.tsx b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/api_keys_grid_page.tsx new file mode 100644 index 0000000000000..6bebf17c943a4 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/api_keys_grid_page.tsx @@ -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 { + 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 ; + } + + if (isLoadingApp) { + return ( + + + + + + ); + } + + if (error) { + const { + body: { error: errorTitle, message, statusCode }, + } = error; + + return ( + + + } + color="danger" + iconType="alert" + > + {statusCode}: {errorTitle} - {message} + + + ); + } + + if (!areApiKeysEnabled) { + return ( + + + + ); + } + + if (!isLoadingTable && apiKeys && apiKeys.length === 0) { + return ( + + + + ); + } + + const description = ( + +

+ {isAdmin ? ( + + ) : ( + + )} +

+
+ ); + + return ( + + + + +

+ +

+
+ {description} +
+
+ + {this.renderTable()} +
+ ); + } + + private renderTable = () => { + const { apiKeys, selectedItems, isLoadingTable, isAdmin } = this.state; + + const message = isLoadingTable ? ( + + ) : ( + 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 ? ( + + {invalidateApiKeyPrompt => { + return ( + + invalidateApiKeyPrompt( + selectedItems.map(({ name, id }) => ({ name, id })), + this.onApiKeysInvalidated + ) + } + color="danger" + data-test-subj="bulkInvalidateActionButton" + > + + + ); + }} + + ) : ( + undefined + ), + toolsRight: ( + this.reloadApiKeys()} + data-test-subj="reloadButton" + > + + + ), + 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 ? ( + <> + + } + color="success" + iconType="user" + size="s" + /> + + + + ) : ( + undefined + )} + + { + { + 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 ( + + {i18n.translate( + 'xpack.security.management.apiKeys.table.expirationDateNeverMessage', + { + defaultMessage: 'Never', + } + )} + + ); + } + + 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 Expired; + } + + return Active; + }, + }, + { + name: i18n.translate('xpack.security.management.apiKeys.table.actionsColumnName', { + defaultMessage: 'Actions', + }), + actions: [ + { + render: ({ name, id }: any) => { + return ( + + + + {invalidateApiKeyPrompt => { + return ( + + + invalidateApiKeyPrompt([{ id, name }], this.onApiKeysInvalidated) + } + /> + + ); + }} + + + + ); + }, + }, + ], + }, + ]); + + 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 }); + }; +} diff --git a/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/empty_prompt/empty_prompt.tsx b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/empty_prompt/empty_prompt.tsx new file mode 100644 index 0000000000000..957ca7010a1a0 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/empty_prompt/empty_prompt.tsx @@ -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 = ({ isAdmin }) => ( + + {isAdmin ? ( + + ) : ( + + )} + + } + body={ + +

+ + + + ), + }} + /> +

+
+ } + actions={ + + + + } + data-test-subj="emptyPrompt" + /> +); diff --git a/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/empty_prompt/index.ts b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/empty_prompt/index.ts new file mode 100644 index 0000000000000..982e34a0ceed5 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/empty_prompt/index.ts @@ -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'; diff --git a/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/index.ts b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/index.ts new file mode 100644 index 0000000000000..9f4d4239d6b4c --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/index.ts @@ -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'; diff --git a/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/invalidate_provider/index.ts b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/invalidate_provider/index.ts new file mode 100644 index 0000000000000..17bfb41fa88b5 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/invalidate_provider/index.ts @@ -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'; diff --git a/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/invalidate_provider/invalidate_provider.tsx b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/invalidate_provider/invalidate_provider.tsx new file mode 100644 index 0000000000000..fe9ffc651db29 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/invalidate_provider/invalidate_provider.tsx @@ -0,0 +1,182 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * 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 = ({ isAdmin, children }) => { + const [apiKeys, setApiKeys] = useState([]); + const [isModalOpen, setIsModalOpen] = useState(false); + const onSuccessCallback = useRef(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 ( + + + {!isSingle ? ( + +

+ {i18n.translate( + 'xpack.security.management.apiKeys.invalidateApiKey.confirmModal.invalidateMultipleListDescription', + { defaultMessage: 'You are about to invalidate these API keys:' } + )} +

+
    + {apiKeys.map(({ name, id }) => ( +
  • {name}
  • + ))} +
+
+ ) : null} +
+
+ ); + }; + + return ( + + {children(invalidateApiKeyPrompt)} + {renderModal()} + + ); +}; diff --git a/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/not_enabled/index.ts b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/not_enabled/index.ts new file mode 100644 index 0000000000000..faa788342fefa --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/not_enabled/index.ts @@ -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'; diff --git a/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/not_enabled/not_enabled.tsx b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/not_enabled/not_enabled.tsx new file mode 100644 index 0000000000000..c419e15450c1e --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/not_enabled/not_enabled.tsx @@ -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 = () => ( + + } + color="danger" + iconType="alert" + > + + + + ), + }} + /> + +); diff --git a/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/permission_denied/index.ts b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/permission_denied/index.ts new file mode 100644 index 0000000000000..8b0bc67f3f777 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/permission_denied/index.ts @@ -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'; diff --git a/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/permission_denied/permission_denied.tsx b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/permission_denied/permission_denied.tsx new file mode 100644 index 0000000000000..d406b1684b3ff --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/permission_denied/permission_denied.tsx @@ -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 = () => ( + + + + + + } + body={ +

+ +

+ } + /> +
+
+); diff --git a/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/services/documentation_links.ts b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/services/documentation_links.ts new file mode 100644 index 0000000000000..1f03763eb542a --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/services/documentation_links.ts @@ -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(); diff --git a/x-pack/legacy/plugins/security/public/views/management/breadcrumbs.ts b/x-pack/legacy/plugins/security/public/views/management/breadcrumbs.ts index a77c8317c1a2c..7d345ac13dc41 100644 --- a/x-pack/legacy/plugins/security/public/views/management/breadcrumbs.ts +++ b/x-pack/legacy/plugins/security/public/views/management/breadcrumbs.ts @@ -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', + }, + ]; +} diff --git a/x-pack/legacy/plugins/security/public/views/management/management.js b/x-pack/legacy/plugins/security/public/views/management/management.js index 7cce644553380..8417191b4ee67 100644 --- a/x-pack/legacy/plugins/security/public/views/management/management.js +++ b/x-pack/legacy/plugins/security/public/views/management/management.js @@ -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) { diff --git a/x-pack/legacy/plugins/security/public/views/management/management_urls.ts b/x-pack/legacy/plugins/security/public/views/management/management_urls.ts index 443b2a313aa5e..ea0cba9f7f3a7 100644 --- a/x-pack/legacy/plugins/security/public/views/management/management_urls.ts +++ b/x-pack/legacy/plugins/security/public/views/management/management_urls.ts @@ -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`; diff --git a/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/get.js b/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/get.js new file mode 100644 index 0000000000000..a236badcd0d6b --- /dev/null +++ b/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/get.js @@ -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(), + }, + } + }); +} diff --git a/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/index.js b/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/index.js new file mode 100644 index 0000000000000..ade1f0974096c --- /dev/null +++ b/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/index.js @@ -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); +} diff --git a/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/invalidate.js b/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/invalidate.js new file mode 100644 index 0000000000000..293142c60be67 --- /dev/null +++ b/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/invalidate.js @@ -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(), + }) + }, + } + }); +} diff --git a/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/privileges.js b/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/privileges.js new file mode 100644 index 0000000000000..3aa30c9a3b9bb --- /dev/null +++ b/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/privileges.js @@ -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] + } + }); +} diff --git a/x-pack/legacy/server/lib/esjs_shield_plugin.js b/x-pack/legacy/server/lib/esjs_shield_plugin.js index 880e055c23985..b6252035aa321 100644 --- a/x-pack/legacy/server/lib/esjs_shield_plugin.js +++ b/x-pack/legacy/server/lib/esjs_shield_plugin.js @@ -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. *