[APM] Add Agent Keys in APM settings - Create agent keys (#120373)

* Add support for creating agent keys

Co-authored-by: Casper Hübertz <casper@formgeist.com>

Co-authored-by: Cauê Marcondes <55978943+cauemarcondes@users.noreply.github.com>
This commit is contained in:
Giorgos Bamparopoulos 2021-12-07 18:33:04 +02:00 committed by GitHub
parent 760374f5a0
commit 79e013f9b1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 655 additions and 58 deletions

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export interface CreateApiKeyResponse {
api_key: string;
expiration?: number;
id: string;
name: string;
}

View file

@ -18,10 +18,10 @@ import { ConfirmDeleteModal } from './confirm_delete_modal';
interface Props {
agentKeys: ApiKey[];
refetchAgentKeys: () => void;
onKeyDelete: () => void;
}
export function AgentKeysTable({ agentKeys, refetchAgentKeys }: Props) {
export function AgentKeysTable({ agentKeys, onKeyDelete }: Props) {
const [agentKeyToBeDeleted, setAgentKeyToBeDeleted] = useState<ApiKey>();
const columns: Array<EuiBasicTableColumn<ApiKey>> = [
@ -159,7 +159,7 @@ export function AgentKeysTable({ agentKeys, refetchAgentKeys }: Props) {
agentKey={agentKeyToBeDeleted}
onConfirm={() => {
setAgentKeyToBeDeleted(undefined);
refetchAgentKeys();
onKeyDelete();
}}
/>
)}

View file

@ -0,0 +1,244 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useState, useEffect } from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiButton,
EuiFlyout,
EuiFlyoutHeader,
EuiTitle,
EuiFlyoutBody,
EuiFlexGroup,
EuiFlexItem,
EuiFlyoutFooter,
EuiButtonEmpty,
EuiForm,
EuiFormRow,
EuiSpacer,
EuiFieldText,
EuiText,
EuiFormFieldset,
EuiCheckbox,
htmlIdGenerator,
} from '@elastic/eui';
import { isEmpty } from 'lodash';
import { callApmApi } from '../../../../services/rest/createCallApmApi';
import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public';
import { ApmPluginStartDeps } from '../../../../plugin';
import { CreateApiKeyResponse } from '../../../../../common/agent_key_types';
interface Props {
onCancel: () => void;
onSuccess: (agentKey: CreateApiKeyResponse) => void;
onError: (keyName: string) => void;
}
export function CreateAgentKeyFlyout({ onCancel, onSuccess, onError }: Props) {
const {
services: { security },
} = useKibana<ApmPluginStartDeps>();
const [username, setUsername] = useState('');
const [formTouched, setFormTouched] = useState(false);
const [keyName, setKeyName] = useState('');
const [agentConfigChecked, setAgentConfigChecked] = useState(true);
const [eventWriteChecked, setEventWriteChecked] = useState(true);
const [sourcemapChecked, setSourcemapChecked] = useState(true);
const isInputInvalid = isEmpty(keyName);
const isFormInvalid = formTouched && isInputInvalid;
const formError = i18n.translate(
'xpack.apm.settings.agentKeys.createKeyFlyout.name.placeholder',
{ defaultMessage: 'Enter a name' }
);
useEffect(() => {
const getCurrentUser = async () => {
try {
const authenticatedUser = await security?.authc.getCurrentUser();
setUsername(authenticatedUser?.username || '');
} catch {
setUsername('');
}
};
getCurrentUser();
}, [security?.authc]);
const createAgentKeyTitle = i18n.translate(
'xpack.apm.settings.agentKeys.createKeyFlyout.createAgentKey',
{ defaultMessage: 'Create agent key' }
);
const createAgentKey = async () => {
setFormTouched(true);
if (isInputInvalid) {
return;
}
try {
const { agentKey } = await callApmApi({
endpoint: 'POST /apm/agent_keys',
signal: null,
params: {
body: {
name: keyName,
sourcemap: sourcemapChecked,
event: eventWriteChecked,
agentConfig: agentConfigChecked,
},
},
});
onSuccess(agentKey);
} catch (error) {
onError(keyName);
}
};
return (
<EuiFlyout onClose={onCancel} size="s">
<EuiFlyoutHeader hasBorder>
<EuiTitle>
<h2>{createAgentKeyTitle}</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<EuiForm isInvalid={isFormInvalid} error={formError}>
{username && (
<EuiFormRow
label={i18n.translate(
'xpack.apm.settings.agentKeys.createKeyFlyout.userTitle',
{ defaultMessage: 'User' }
)}
>
<EuiText>{username}</EuiText>
</EuiFormRow>
)}
<EuiFormRow
label={i18n.translate(
'xpack.apm.settings.agentKeys.createKeyFlyout.nameTitle',
{
defaultMessage: 'Name',
}
)}
helpText={i18n.translate(
'xpack.apm.settings.agentKeys.createKeyFlyout.nameHelpText',
{
defaultMessage: 'What is this key used for?',
}
)}
isInvalid={isFormInvalid}
error={formError}
>
<EuiFieldText
name="name"
placeholder={i18n.translate(
'xpack.apm.settings.agentKeys.createKeyFlyout.namePlaceholder',
{
defaultMessage: 'e.g. apm-key',
}
)}
onChange={(e) => setKeyName(e.target.value)}
isInvalid={isFormInvalid}
onBlur={() => setFormTouched(true)}
/>
</EuiFormRow>
<EuiSpacer size="m" />
<EuiFormFieldset
legend={{
children: i18n.translate(
'xpack.apm.settings.agentKeys.createKeyFlyout.privilegesFieldset',
{
defaultMessage: 'Assign privileges',
}
),
}}
>
<EuiFormRow
helpText={i18n.translate(
'xpack.apm.settings.agentKeys.createKeyFlyout.agentConfigHelpText',
{
defaultMessage:
'Required for agents to read agent configuration remotely.',
}
)}
>
<EuiCheckbox
id={htmlIdGenerator()()}
label="config_agent:read"
checked={agentConfigChecked}
onChange={() => setAgentConfigChecked((state) => !state)}
/>
</EuiFormRow>
<EuiSpacer size="s" />
<EuiFormRow
helpText={i18n.translate(
'xpack.apm.settings.agentKeys.createKeyFlyout.ingestAgentEvents',
{
defaultMessage: 'Required for ingesting events.',
}
)}
>
<EuiCheckbox
id={htmlIdGenerator()()}
label="event:write"
checked={eventWriteChecked}
onChange={() => setEventWriteChecked((state) => !state)}
/>
</EuiFormRow>
<EuiSpacer size="s" />
<EuiFormRow
helpText={i18n.translate(
'xpack.apm.settings.agentKeys.createKeyFlyout.sourcemaps',
{
defaultMessage: 'Required for uploading sourcemaps.',
}
)}
>
<EuiCheckbox
id={htmlIdGenerator()()}
label="sourcemap:write"
checked={sourcemapChecked}
onChange={() => setSourcemapChecked((state) => !state)}
/>
</EuiFormRow>
<EuiSpacer size="s" />
</EuiFormFieldset>
</EuiForm>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={onCancel}>
{i18n.translate(
'xpack.apm.settings.agentKeys.createKeyFlyout.cancelButton',
{
defaultMessage: 'Cancel',
}
)}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
fill={true}
onClick={createAgentKey}
type="submit"
disabled={isFormInvalid}
>
{createAgentKeyTitle}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
);
}

View file

@ -0,0 +1,86 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiSpacer,
EuiCallOut,
EuiButtonIcon,
EuiCopy,
EuiFormControlLayout,
} from '@elastic/eui';
interface Props {
name: string;
token: string;
}
export function AgentKeyCallOut({ name, token }: Props) {
return (
<>
<EuiCallOut
title={i18n.translate(
'xpack.apm.settings.agentKeys.copyAgentKeyField.title',
{
defaultMessage: 'Created "{name}" key',
values: { name },
}
)}
color="success"
iconType="check"
>
<p>
{i18n.translate(
'xpack.apm.settings.agentKeys.copyAgentKeyField.message',
{
defaultMessage:
'Copy this key now. You will not be able to view it again.',
}
)}
</p>
<EuiFormControlLayout
style={{ backgroundColor: 'transparent' }}
readOnly
prepend="Base64"
append={
<EuiCopy textToCopy={token}>
{(copy) => (
<EuiButtonIcon
iconType="copyClipboard"
onClick={copy}
color="success"
style={{ backgroundColor: 'transparent' }}
aria-label={i18n.translate(
'xpack.apm.settings.agentKeys.copyAgentKeyField.copyButton',
{
defaultMessage: 'Copy to clipboard',
}
)}
/>
)}
</EuiCopy>
}
>
<input
type="text"
className="euiFieldText euiFieldText--inGroup"
readOnly
value={token}
aria-label={i18n.translate(
'xpack.apm.settings.agentKeys.copyAgentKeyField.agentKeyLabel',
{
defaultMessage: 'Agent key',
}
)}
/>
</EuiFormControlLayout>
</EuiCallOut>
<EuiSpacer size="m" />
</>
);
}

View file

@ -4,7 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { Fragment } from 'react';
import React, { Fragment, useState } from 'react';
import { isEmpty } from 'lodash';
import { i18n } from '@kbn/i18n';
import {
@ -21,6 +21,11 @@ import { useFetcher, FETCH_STATUS } from '../../../../hooks/use_fetcher';
import { PermissionDenied } from './prompts/permission_denied';
import { ApiKeysNotEnabled } from './prompts/api_keys_not_enabled';
import { AgentKeysTable } from './agent_keys_table';
import { CreateAgentKeyFlyout } from './create_agent_key';
import { AgentKeyCallOut } from './create_agent_key/agent_key_callout';
import { CreateApiKeyResponse } from '../../../../../common/agent_key_types';
import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plugin_context';
import { ApiKey } from '../../../../../../security/common/model';
const INITIAL_DATA = {
areApiKeysEnabled: false,
@ -28,33 +33,12 @@ const INITIAL_DATA = {
};
export function AgentKeys() {
return (
<Fragment>
<EuiText color="subdued">
{i18n.translate('xpack.apm.settings.agentKeys.descriptionText', {
defaultMessage:
'View and delete agent keys. An agent key sends requests on behalf of a user.',
})}
</EuiText>
<EuiSpacer size="m" />
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiTitle>
<h2>
{i18n.translate('xpack.apm.settings.agentKeys.title', {
defaultMessage: 'Agent keys',
})}
</h2>
</EuiTitle>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<AgentKeysContent />
</Fragment>
);
}
const { toasts } = useApmPluginContext().core.notifications;
const [isFlyoutVisible, setIsFlyoutVisible] = useState(false);
const [createdAgentKey, setCreatedAgentKey] =
useState<CreateApiKeyResponse>();
function AgentKeysContent() {
const {
data: { areApiKeysEnabled, canManage } = INITIAL_DATA,
status: privilegesStatus,
@ -85,16 +69,112 @@ function AgentKeysContent() {
);
const agentKeys = data?.agentKeys;
const isLoading =
privilegesStatus === FETCH_STATUS.LOADING ||
status === FETCH_STATUS.LOADING;
const requestFailed =
privilegesStatus === FETCH_STATUS.FAILURE ||
status === FETCH_STATUS.FAILURE;
return (
<Fragment>
<EuiText color="subdued">
{i18n.translate('xpack.apm.settings.agentKeys.descriptionText', {
defaultMessage:
'View and delete agent keys. An agent key sends requests on behalf of a user.',
})}
</EuiText>
<EuiSpacer size="m" />
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiTitle>
<h2>
{i18n.translate('xpack.apm.settings.agentKeys.title', {
defaultMessage: 'Agent keys',
})}
</h2>
</EuiTitle>
</EuiFlexItem>
{areApiKeysEnabled && canManage && !isEmpty(agentKeys) && (
<EuiFlexItem grow={false}>
<EuiButton
onClick={() => setIsFlyoutVisible(true)}
fill={true}
iconType="plusInCircle"
>
{i18n.translate(
'xpack.apm.settings.agentKeys.createAgentKeyButton',
{
defaultMessage: 'Create agent key',
}
)}
</EuiButton>
</EuiFlexItem>
)}
</EuiFlexGroup>
<EuiSpacer size="m" />
{createdAgentKey && (
<AgentKeyCallOut
name={createdAgentKey.name}
token={btoa(`${createdAgentKey.id}:${createdAgentKey.api_key}`)}
/>
)}
{isFlyoutVisible && (
<CreateAgentKeyFlyout
onCancel={() => {
setIsFlyoutVisible(false);
}}
onSuccess={(agentKey: CreateApiKeyResponse) => {
setCreatedAgentKey(agentKey);
setIsFlyoutVisible(false);
refetchAgentKeys();
}}
onError={(keyName: string) => {
toasts.addDanger(
i18n.translate('xpack.apm.settings.agentKeys.crate.failed', {
defaultMessage: 'Error creating agent key "{keyName}"',
values: { keyName },
})
);
setIsFlyoutVisible(false);
}}
/>
)}
<AgentKeysContent
loading={
privilegesStatus === FETCH_STATUS.LOADING ||
status === FETCH_STATUS.LOADING
}
requestFailed={
privilegesStatus === FETCH_STATUS.FAILURE ||
status === FETCH_STATUS.FAILURE
}
canManage={canManage}
areApiKeysEnabled={areApiKeysEnabled}
agentKeys={agentKeys}
onKeyDelete={() => {
setCreatedAgentKey(undefined);
refetchAgentKeys();
}}
onCreateAgentClick={() => setIsFlyoutVisible(true)}
/>
</Fragment>
);
}
function AgentKeysContent({
loading,
requestFailed,
canManage,
areApiKeysEnabled,
agentKeys,
onKeyDelete,
onCreateAgentClick,
}: {
loading: boolean;
requestFailed: boolean;
canManage: boolean;
areApiKeysEnabled: boolean;
agentKeys?: ApiKey[];
onKeyDelete: () => void;
onCreateAgentClick: () => void;
}) {
if (!agentKeys) {
if (isLoading) {
if (loading) {
return (
<EuiEmptyPrompt
icon={<EuiLoadingSpinner size="xl" />}
@ -147,7 +227,7 @@ function AgentKeysContent() {
title={
<h2>
{i18n.translate('xpack.apm.settings.agentKeys.emptyPromptTitle', {
defaultMessage: 'Create your first agent key',
defaultMessage: 'Create your first key',
})}
</h2>
}
@ -155,12 +235,16 @@ function AgentKeysContent() {
<p>
{i18n.translate('xpack.apm.settings.agentKeys.emptyPromptBody', {
defaultMessage:
'Create agent keys to authorize requests to the APM Server.',
'Create keys to authorize agent requests to the APM Server.',
})}
</p>
}
actions={
<EuiButton fill={true} iconType="plusInCircleFilled">
<EuiButton
onClick={onCreateAgentClick}
fill={true}
iconType="plusInCircle"
>
{i18n.translate(
'xpack.apm.settings.agentKeys.createAgentKeyButton',
{
@ -175,10 +259,7 @@ function AgentKeysContent() {
if (agentKeys && !isEmpty(agentKeys)) {
return (
<AgentKeysTable
agentKeys={agentKeys ?? []}
refetchAgentKeys={refetchAgentKeys}
/>
<AgentKeysTable agentKeys={agentKeys ?? []} onKeyDelete={onKeyDelete} />
);
}

View file

@ -18,11 +18,11 @@ import { getLegacyApmHref } from '../../shared/Links/apm/APMLink';
type Tab = NonNullable<EuiPageHeaderProps['tabs']>[0] & {
key:
| 'agent-configurations'
| 'agent-keys'
| 'anomaly-detection'
| 'apm-indices'
| 'customize-ui'
| 'schema'
| 'agent-keys';
| 'schema';
hidden?: boolean;
};
@ -76,6 +76,17 @@ function getTabs({
search,
}),
},
{
key: 'agent-keys',
label: i18n.translate('xpack.apm.settings.agentKeys', {
defaultMessage: 'Agent Keys',
}),
href: getLegacyApmHref({
basePath,
path: `/settings/agent-keys`,
search,
}),
},
{
key: 'anomaly-detection',
label: i18n.translate('xpack.apm.settings.anomalyDetection', {
@ -117,17 +128,6 @@ function getTabs({
}),
href: getLegacyApmHref({ basePath, path: `/settings/schema`, search }),
},
{
key: 'agent-keys',
label: i18n.translate('xpack.apm.settings.agentKeys', {
defaultMessage: 'Agent Keys',
}),
href: getLegacyApmHref({
basePath,
path: `/settings/agent-keys`,
search,
}),
},
];
return tabs

View file

@ -54,6 +54,8 @@ import { getLazyApmAgentsTabExtension } from './components/fleet_integration/laz
import { getLazyAPMPolicyCreateExtension } from './components/fleet_integration/lazy_apm_policy_create_extension';
import { getLazyAPMPolicyEditExtension } from './components/fleet_integration/lazy_apm_policy_edit_extension';
import { featureCatalogueEntry } from './featureCatalogueEntry';
import type { SecurityPluginStart } from '../../security/public';
export type ApmPluginSetup = ReturnType<ApmPlugin['setup']>;
export type ApmPluginStart = void;
@ -81,6 +83,7 @@ export interface ApmPluginStartDeps {
triggersActionsUi: TriggersAndActionsUIPublicPluginStart;
observability: ObservabilityPublicStart;
fleet?: FleetStart;
security?: SecurityPluginStart;
}
const servicesTitle = i18n.translate('xpack.apm.navigation.servicesTitle', {

View file

@ -0,0 +1,138 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import Boom from '@hapi/boom';
import { ApmPluginRequestHandlerContext } from '../typings';
import { CreateApiKeyResponse } from '../../../common/agent_key_types';
const enum PrivilegeType {
SOURCEMAP = 'sourcemap:write',
EVENT = 'event:write',
AGENT_CONFIG = 'config_agent:read',
}
interface SecurityHasPrivilegesResponse {
application: {
apm: {
'-': {
[PrivilegeType.SOURCEMAP]: boolean;
[PrivilegeType.EVENT]: boolean;
[PrivilegeType.AGENT_CONFIG]: boolean;
};
};
};
has_all_requested: boolean;
username: string;
}
export async function createAgentKey({
context,
requestBody,
}: {
context: ApmPluginRequestHandlerContext;
requestBody: {
name: string;
sourcemap?: boolean;
event?: boolean;
agentConfig?: boolean;
};
}) {
// Elasticsearch will allow a user without the right apm privileges to create API keys, but the keys won't validate
// check first whether the user has the right privileges, and bail out early if not
const {
body: { application, username, has_all_requested: hasRequiredPrivileges },
} = await context.core.elasticsearch.client.asCurrentUser.security.hasPrivileges<SecurityHasPrivilegesResponse>(
{
body: {
application: [
{
application: 'apm',
privileges: [
PrivilegeType.SOURCEMAP,
PrivilegeType.EVENT,
PrivilegeType.AGENT_CONFIG,
],
resources: ['-'],
},
],
},
}
);
if (!hasRequiredPrivileges) {
const missingPrivileges = Object.entries(application.apm['-'])
.filter((x) => !x[1])
.map((x) => x[0])
.join(', ');
const error = `${username} is missing the following requested privilege(s): ${missingPrivileges}.\
You might try with the superuser, or add the APM application privileges to the role of the authenticated user, eg.:
PUT /_security/role/my_role {
...
"applications": [{
"application": "apm",
"privileges": ["sourcemap:write", "event:write", "config_agent:read"],
"resources": ["*"]
}],
...
}`;
throw Boom.internal(error);
}
const { name = 'apm-key', sourcemap, event, agentConfig } = requestBody;
const privileges: PrivilegeType[] = [];
if (!sourcemap && !event && !agentConfig) {
privileges.push(
PrivilegeType.SOURCEMAP,
PrivilegeType.EVENT,
PrivilegeType.AGENT_CONFIG
);
}
if (sourcemap) {
privileges.push(PrivilegeType.SOURCEMAP);
}
if (event) {
privileges.push(PrivilegeType.EVENT);
}
if (agentConfig) {
privileges.push(PrivilegeType.AGENT_CONFIG);
}
const body = {
name,
metadata: {
application: 'apm',
},
role_descriptors: {
apm: {
cluster: [],
index: [],
applications: [
{
application: 'apm',
privileges,
resources: ['*'],
},
],
},
},
};
const { body: agentKey } =
await context.core.elasticsearch.client.asCurrentUser.security.createApiKey<CreateApiKeyResponse>(
{
body,
}
);
return {
agentKey,
};
}

View file

@ -8,11 +8,13 @@
import Boom from '@hapi/boom';
import { i18n } from '@kbn/i18n';
import * as t from 'io-ts';
import { toBooleanRt } from '@kbn/io-ts-utils/to_boolean_rt';
import { createApmServerRoute } from '../apm_routes/create_apm_server_route';
import { createApmServerRouteRepository } from '../apm_routes/create_apm_server_route_repository';
import { getAgentKeys } from './get_agent_keys';
import { getAgentKeysPrivileges } from './get_agent_keys_privileges';
import { invalidateAgentKey } from './invalidate_agent_key';
import { createAgentKey } from './create_agent_key';
const agentKeysRoute = createApmServerRoute({
endpoint: 'GET /internal/apm/agent_keys',
@ -74,10 +76,40 @@ const invalidateAgentKeyRoute = createApmServerRoute({
},
});
const createAgentKeyRoute = createApmServerRoute({
endpoint: 'POST /apm/agent_keys',
options: { tags: ['access:apm', 'access:apm_write'] },
params: t.type({
body: t.intersection([
t.partial({
sourcemap: toBooleanRt,
event: toBooleanRt,
agentConfig: toBooleanRt,
}),
t.type({
name: t.string,
}),
]),
}),
handler: async (resources) => {
const { context, params } = resources;
const { body: requestBody } = params;
const agentKey = await createAgentKey({
context,
requestBody,
});
return agentKey;
},
});
export const agentKeysRouteRepository = createApmServerRouteRepository()
.add(agentKeysRoute)
.add(agentKeysPrivilegesRoute)
.add(invalidateAgentKeyRoute);
.add(invalidateAgentKeyRoute)
.add(createAgentKeyRoute);
const SECURITY_REQUIRED_MESSAGE = i18n.translate(
'xpack.apm.api.apiKeys.securityRequired',