diff --git a/x-pack/plugins/apm/common/agent_key_types.ts b/x-pack/plugins/apm/common/agent_key_types.ts new file mode 100644 index 00000000000000..986e67d35698e4 --- /dev/null +++ b/x-pack/plugins/apm/common/agent_key_types.ts @@ -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; +} diff --git a/x-pack/plugins/apm/public/components/app/Settings/agent_keys/agent_keys_table.tsx b/x-pack/plugins/apm/public/components/app/Settings/agent_keys/agent_keys_table.tsx index 4a05f38d8e505a..f49264242e63f0 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/agent_keys/agent_keys_table.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/agent_keys/agent_keys_table.tsx @@ -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(); const columns: Array> = [ @@ -159,7 +159,7 @@ export function AgentKeysTable({ agentKeys, refetchAgentKeys }: Props) { agentKey={agentKeyToBeDeleted} onConfirm={() => { setAgentKeyToBeDeleted(undefined); - refetchAgentKeys(); + onKeyDelete(); }} /> )} diff --git a/x-pack/plugins/apm/public/components/app/Settings/agent_keys/create_agent_key.tsx b/x-pack/plugins/apm/public/components/app/Settings/agent_keys/create_agent_key.tsx new file mode 100644 index 00000000000000..5803e5a2a75a88 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/Settings/agent_keys/create_agent_key.tsx @@ -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(); + + 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 ( + + + +

{createAgentKeyTitle}

+
+
+ + + + {username && ( + + {username} + + )} + + setKeyName(e.target.value)} + isInvalid={isFormInvalid} + onBlur={() => setFormTouched(true)} + /> + + + + + setAgentConfigChecked((state) => !state)} + /> + + + + setEventWriteChecked((state) => !state)} + /> + + + + setSourcemapChecked((state) => !state)} + /> + + + + + + + + + + + {i18n.translate( + 'xpack.apm.settings.agentKeys.createKeyFlyout.cancelButton', + { + defaultMessage: 'Cancel', + } + )} + + + + + {createAgentKeyTitle} + + + + +
+ ); +} diff --git a/x-pack/plugins/apm/public/components/app/Settings/agent_keys/create_agent_key/agent_key_callout.tsx b/x-pack/plugins/apm/public/components/app/Settings/agent_keys/create_agent_key/agent_key_callout.tsx new file mode 100644 index 00000000000000..db313e35a0229d --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/Settings/agent_keys/create_agent_key/agent_key_callout.tsx @@ -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 ( + <> + +

+ {i18n.translate( + 'xpack.apm.settings.agentKeys.copyAgentKeyField.message', + { + defaultMessage: + 'Copy this key now. You will not be able to view it again.', + } + )} +

+ + {(copy) => ( + + )} + + } + > + + +
+ + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/Settings/agent_keys/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/agent_keys/index.tsx index 23acc2e98dd736..8fb4ede96a819d 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/agent_keys/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/agent_keys/index.tsx @@ -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 ( - - - {i18n.translate('xpack.apm.settings.agentKeys.descriptionText', { - defaultMessage: - 'View and delete agent keys. An agent key sends requests on behalf of a user.', - })} - - - - - -

- {i18n.translate('xpack.apm.settings.agentKeys.title', { - defaultMessage: 'Agent keys', - })} -

-
-
-
- - -
- ); -} + const { toasts } = useApmPluginContext().core.notifications; + + const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); + const [createdAgentKey, setCreatedAgentKey] = + useState(); -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 ( + + + {i18n.translate('xpack.apm.settings.agentKeys.descriptionText', { + defaultMessage: + 'View and delete agent keys. An agent key sends requests on behalf of a user.', + })} + + + + + +

+ {i18n.translate('xpack.apm.settings.agentKeys.title', { + defaultMessage: 'Agent keys', + })} +

+
+
+ {areApiKeysEnabled && canManage && !isEmpty(agentKeys) && ( + + setIsFlyoutVisible(true)} + fill={true} + iconType="plusInCircle" + > + {i18n.translate( + 'xpack.apm.settings.agentKeys.createAgentKeyButton', + { + defaultMessage: 'Create agent key', + } + )} + + + )} +
+ + {createdAgentKey && ( + + )} + {isFlyoutVisible && ( + { + 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); + }} + /> + )} + { + setCreatedAgentKey(undefined); + refetchAgentKeys(); + }} + onCreateAgentClick={() => setIsFlyoutVisible(true)} + /> +
+ ); +} +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 ( } @@ -147,7 +227,7 @@ function AgentKeysContent() { title={

{i18n.translate('xpack.apm.settings.agentKeys.emptyPromptTitle', { - defaultMessage: 'Create your first agent key', + defaultMessage: 'Create your first key', })}

} @@ -155,12 +235,16 @@ function AgentKeysContent() {

{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.', })}

} actions={ - + {i18n.translate( 'xpack.apm.settings.agentKeys.createAgentKeyButton', { @@ -175,10 +259,7 @@ function AgentKeysContent() { if (agentKeys && !isEmpty(agentKeys)) { return ( - + ); } diff --git a/x-pack/plugins/apm/public/components/routing/templates/settings_template.tsx b/x-pack/plugins/apm/public/components/routing/templates/settings_template.tsx index ec8366dfb36b4e..229f34f7857adc 100644 --- a/x-pack/plugins/apm/public/components/routing/templates/settings_template.tsx +++ b/x-pack/plugins/apm/public/components/routing/templates/settings_template.tsx @@ -18,11 +18,11 @@ import { getLegacyApmHref } from '../../shared/Links/apm/APMLink'; type Tab = NonNullable[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 diff --git a/x-pack/plugins/apm/public/plugin.ts b/x-pack/plugins/apm/public/plugin.ts index 3a439df245609d..d62cca4e07d450 100644 --- a/x-pack/plugins/apm/public/plugin.ts +++ b/x-pack/plugins/apm/public/plugin.ts @@ -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; 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', { diff --git a/x-pack/plugins/apm/server/routes/agent_keys/create_agent_key.ts b/x-pack/plugins/apm/server/routes/agent_keys/create_agent_key.ts new file mode 100644 index 00000000000000..02207dad32efbb --- /dev/null +++ b/x-pack/plugins/apm/server/routes/agent_keys/create_agent_key.ts @@ -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( + { + 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( + { + body, + } + ); + + return { + agentKey, + }; +} diff --git a/x-pack/plugins/apm/server/routes/agent_keys/route.ts b/x-pack/plugins/apm/server/routes/agent_keys/route.ts index e5f40205b29121..44bbb22e703b54 100644 --- a/x-pack/plugins/apm/server/routes/agent_keys/route.ts +++ b/x-pack/plugins/apm/server/routes/agent_keys/route.ts @@ -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',