mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
* 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:
parent
bad9c8b762
commit
f2e91f6c3a
28 changed files with 1349 additions and 1 deletions
|
@ -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';
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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';
|
||||
|
|
20
x-pack/legacy/plugins/security/common/model/api_key.ts
Normal file
20
x-pack/legacy/plugins/security/common/model/api_key.ts
Normal 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;
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
|
|
48
x-pack/legacy/plugins/security/public/lib/api_keys_api.ts
Normal file
48
x-pack/legacy/plugins/security/public/lib/api_keys_api.ts
Normal 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 });
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
<kbn-management-app section="security/api_keys">
|
||||
<div id="apiKeysGridReactRoot" />
|
||||
</kbn-management-app>
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
|
@ -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 });
|
||||
};
|
||||
}
|
|
@ -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"
|
||||
/>
|
||||
);
|
|
@ -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';
|
|
@ -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';
|
|
@ -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';
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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';
|
|
@ -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>
|
||||
);
|
|
@ -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';
|
|
@ -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>
|
||||
);
|
|
@ -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();
|
|
@ -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',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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`;
|
||||
|
|
|
@ -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(),
|
||||
},
|
||||
}
|
||||
});
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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(),
|
||||
})
|
||||
},
|
||||
}
|
||||
});
|
||||
}
|
|
@ -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]
|
||||
}
|
||||
});
|
||||
}
|
|
@ -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.
|
||||
*
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue