[APM] agent keys management improvements (#120765) (#120943)

* Add userCurrentUser hook

* Use EuiFieldText instead of input element

* Display error messages in the UI when creating agent keys

* Remove default agent key name

* Prefix createAgentKeyRoute with /api

* Fix issue where you cannot invalidate API keys when you only have manage_own_api_key privilege

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

Co-authored-by: Giorgos Bamparopoulos <georgios.bamparopoulos@elastic.co>
Co-authored-by: Casper Hübertz <casper@formgeist.com>
This commit is contained in:
Kibana Machine 2021-12-09 12:44:29 -05:00 committed by GitHub
parent 8da7e923bd
commit bc5533b022
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 179 additions and 146 deletions

View file

@ -0,0 +1,22 @@
/*
* 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 * as t from 'io-ts';
export const enum PrivilegeType {
SOURCEMAP = 'sourcemap:write',
EVENT = 'event:write',
AGENT_CONFIG = 'config_agent:read',
}
export const privilegesTypeRt = t.array(
t.union([
t.literal(PrivilegeType.SOURCEMAP),
t.literal(PrivilegeType.EVENT),
t.literal(PrivilegeType.AGENT_CONFIG),
])
);

View file

@ -82,7 +82,7 @@ export function AgentKeysTable({ agentKeys, onKeyDelete }: Props) {
description: i18n.translate(
'xpack.apm.settings.agentKeys.table.deleteActionDescription',
{
defaultMessage: 'Delete this agent key',
defaultMessage: 'Delete this APM agent key',
}
),
icon: 'trash',
@ -144,7 +144,7 @@ export function AgentKeysTable({ agentKeys, onKeyDelete }: Props) {
tableCaption={i18n.translate(
'xpack.apm.settings.agentKeys.tableCaption',
{
defaultMessage: 'Agent keys',
defaultMessage: 'APM agent keys',
}
)}
items={agentKeys ?? []}

View file

@ -34,14 +34,14 @@ export function ConfirmDeleteModal({ agentKey, onCancel, onConfirm }: Props) {
});
toasts.addSuccess(
i18n.translate('xpack.apm.settings.agentKeys.invalidate.succeeded', {
defaultMessage: 'Deleted agent key "{name}"',
defaultMessage: 'Deleted APM agent key "{name}"',
values: { name },
})
);
} catch (error) {
toasts.addDanger(
i18n.translate('xpack.apm.settings.agentKeys.invalidate.failed', {
defaultMessage: 'Error deleting agent key "{name}"',
defaultMessage: 'Error deleting APM agent key "{name}"',
values: { name },
})
);
@ -53,7 +53,7 @@ export function ConfirmDeleteModal({ agentKey, onCancel, onConfirm }: Props) {
title={i18n.translate(
'xpack.apm.settings.agentKeys.deleteConfirmModal.title',
{
defaultMessage: 'Delete agent key "{name}"?',
defaultMessage: 'Delete APM agent key "{name}"?',
values: { name },
}
)}

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useState, useEffect } from 'react';
import React, { useState } from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiButton,
@ -28,30 +28,31 @@ import {
} 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';
import { useCurrentUser } from '../../../../hooks/use_current_user';
import { PrivilegeType } from '../../../../../common/privilege_type';
interface Props {
onCancel: () => void;
onSuccess: (agentKey: CreateApiKeyResponse) => void;
onError: (keyName: string) => void;
onError: (keyName: string, message: 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 [agentKeyBody, setAgentKeyBody] = useState({
name: '',
sourcemap: true,
event: true,
agentConfig: true,
});
const { name, sourcemap, event, agentConfig } = agentKeyBody;
const currentUser = useCurrentUser();
const isInputInvalid = isEmpty(name);
const isFormInvalid = formTouched && isInputInvalid;
const formError = i18n.translate(
@ -59,21 +60,9 @@ export function CreateAgentKeyFlyout({ onCancel, onSuccess, onError }: Props) {
{ 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' }
{ defaultMessage: 'Create APM agent key' }
);
const createAgentKey = async () => {
@ -83,22 +72,33 @@ export function CreateAgentKeyFlyout({ onCancel, onSuccess, onError }: Props) {
}
try {
const privileges: PrivilegeType[] = [];
if (sourcemap) {
privileges.push(PrivilegeType.SOURCEMAP);
}
if (event) {
privileges.push(PrivilegeType.EVENT);
}
if (agentConfig) {
privileges.push(PrivilegeType.AGENT_CONFIG);
}
const { agentKey } = await callApmApi({
endpoint: 'POST /apm/agent_keys',
endpoint: 'POST /api/apm/agent_keys',
signal: null,
params: {
body: {
name: keyName,
sourcemap: sourcemapChecked,
event: eventWriteChecked,
agentConfig: agentConfigChecked,
name,
privileges,
},
},
});
onSuccess(agentKey);
} catch (error) {
onError(keyName);
onError(name, error.body?.message || error.message);
}
};
@ -112,14 +112,14 @@ export function CreateAgentKeyFlyout({ onCancel, onSuccess, onError }: Props) {
<EuiFlyoutBody>
<EuiForm isInvalid={isFormInvalid} error={formError}>
{username && (
{currentUser && (
<EuiFormRow
label={i18n.translate(
'xpack.apm.settings.agentKeys.createKeyFlyout.userTitle',
{ defaultMessage: 'User' }
)}
>
<EuiText>{username}</EuiText>
<EuiText>{currentUser?.username}</EuiText>
</EuiFormRow>
)}
<EuiFormRow
@ -146,7 +146,9 @@ export function CreateAgentKeyFlyout({ onCancel, onSuccess, onError }: Props) {
defaultMessage: 'e.g. apm-key',
}
)}
onChange={(e) => setKeyName(e.target.value)}
onChange={(e) =>
setAgentKeyBody((state) => ({ ...state, name: e.target.value }))
}
isInvalid={isFormInvalid}
onBlur={() => setFormTouched(true)}
/>
@ -174,8 +176,13 @@ export function CreateAgentKeyFlyout({ onCancel, onSuccess, onError }: Props) {
<EuiCheckbox
id={htmlIdGenerator()()}
label="config_agent:read"
checked={agentConfigChecked}
onChange={() => setAgentConfigChecked((state) => !state)}
checked={agentConfig}
onChange={() =>
setAgentKeyBody((state) => ({
...state,
agentConfig: !state.agentConfig,
}))
}
/>
</EuiFormRow>
<EuiSpacer size="s" />
@ -190,8 +197,13 @@ export function CreateAgentKeyFlyout({ onCancel, onSuccess, onError }: Props) {
<EuiCheckbox
id={htmlIdGenerator()()}
label="event:write"
checked={eventWriteChecked}
onChange={() => setEventWriteChecked((state) => !state)}
checked={event}
onChange={() =>
setAgentKeyBody((state) => ({
...state,
event: !state.event,
}))
}
/>
</EuiFormRow>
<EuiSpacer size="s" />
@ -206,8 +218,13 @@ export function CreateAgentKeyFlyout({ onCancel, onSuccess, onError }: Props) {
<EuiCheckbox
id={htmlIdGenerator()()}
label="sourcemap:write"
checked={sourcemapChecked}
onChange={() => setSourcemapChecked((state) => !state)}
checked={sourcemap}
onChange={() =>
setAgentKeyBody((state) => ({
...state,
sourcemap: !state.sourcemap,
}))
}
/>
</EuiFormRow>
<EuiSpacer size="s" />

View file

@ -12,7 +12,7 @@ import {
EuiCallOut,
EuiButtonIcon,
EuiCopy,
EuiFormControlLayout,
EuiFieldText,
} from '@elastic/eui';
interface Props {
@ -43,9 +43,15 @@ export function AgentKeyCallOut({ name, token }: Props) {
}
)}
</p>
<EuiFormControlLayout
style={{ backgroundColor: 'transparent' }}
<EuiFieldText
readOnly
value={token}
aria-label={i18n.translate(
'xpack.apm.settings.agentKeys.copyAgentKeyField.agentKeyLabel',
{
defaultMessage: 'APM agent key',
}
)}
prepend="Base64"
append={
<EuiCopy textToCopy={token}>
@ -65,20 +71,7 @@ export function AgentKeyCallOut({ name, token }: Props) {
)}
</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

@ -75,7 +75,7 @@ export function AgentKeys() {
<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.',
'View and delete APM agent keys. An APM agent key sends requests on behalf of a user.',
})}
</EuiText>
<EuiSpacer size="m" />
@ -84,7 +84,7 @@ export function AgentKeys() {
<EuiTitle>
<h2>
{i18n.translate('xpack.apm.settings.agentKeys.title', {
defaultMessage: 'Agent keys',
defaultMessage: 'APM agent keys',
})}
</h2>
</EuiTitle>
@ -99,7 +99,7 @@ export function AgentKeys() {
{i18n.translate(
'xpack.apm.settings.agentKeys.createAgentKeyButton',
{
defaultMessage: 'Create agent key',
defaultMessage: 'Create APM agent key',
}
)}
</EuiButton>
@ -123,11 +123,12 @@ export function AgentKeys() {
setIsFlyoutVisible(false);
refetchAgentKeys();
}}
onError={(keyName: string) => {
onError={(keyName: string, message: string) => {
toasts.addDanger(
i18n.translate('xpack.apm.settings.agentKeys.crate.failed', {
defaultMessage: 'Error creating agent key "{keyName}"',
values: { keyName },
defaultMessage:
'Error creating APM agent key "{keyName}". Error: "{message}"',
values: { keyName, message },
})
);
setIsFlyoutVisible(false);
@ -184,7 +185,7 @@ function AgentKeysContent({
{i18n.translate(
'xpack.apm.settings.agentKeys.agentKeysLoadingPromptTitle',
{
defaultMessage: 'Loading Agent keys...',
defaultMessage: 'Loading APM agent keys...',
}
)}
</h2>
@ -202,7 +203,7 @@ function AgentKeysContent({
{i18n.translate(
'xpack.apm.settings.agentKeys.agentKeysErrorPromptTitle',
{
defaultMessage: 'Could not load agent keys.',
defaultMessage: 'Could not load APM agent keys.',
}
)}
</h2>
@ -235,7 +236,7 @@ function AgentKeysContent({
<p>
{i18n.translate('xpack.apm.settings.agentKeys.emptyPromptBody', {
defaultMessage:
'Create keys to authorize agent requests to the APM Server.',
'Create APM agent keys to authorize APM agent requests to the APM Server.',
})}
</p>
}
@ -248,7 +249,7 @@ function AgentKeysContent({
{i18n.translate(
'xpack.apm.settings.agentKeys.createAgentKeyButton',
{
defaultMessage: 'Create agent key',
defaultMessage: 'Create APM agent key',
}
)}
</EuiButton>

View file

@ -0,0 +1,33 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useState, useEffect } from 'react';
import { useKibana } from '../../../../../src/plugins/kibana_react/public';
import { ApmPluginStartDeps } from '../plugin';
import { AuthenticatedUser } from '../../../security/common/model';
export function useCurrentUser() {
const {
services: { security },
} = useKibana<ApmPluginStartDeps>();
const [user, setUser] = useState<AuthenticatedUser>();
useEffect(() => {
const getCurrentUser = async () => {
try {
const authenticatedUser = await security?.authc.getCurrentUser();
setUser(authenticatedUser);
} catch {
setUser(undefined);
}
};
getCurrentUser();
}, [security?.authc]);
return user;
}

View file

@ -8,17 +8,14 @@
import Boom from '@hapi/boom';
import { ApmPluginRequestHandlerContext } from '../typings';
import { CreateApiKeyResponse } from '../../../common/agent_key_types';
import { PrivilegeType } from '../../../common/privilege_type';
const enum PrivilegeType {
SOURCEMAP = 'sourcemap:write',
EVENT = 'event:write',
AGENT_CONFIG = 'config_agent:read',
}
const resource = '*';
interface SecurityHasPrivilegesResponse {
application: {
apm: {
'-': {
[resource]: {
[PrivilegeType.SOURCEMAP]: boolean;
[PrivilegeType.EVENT]: boolean;
[PrivilegeType.AGENT_CONFIG]: boolean;
@ -36,75 +33,56 @@ export async function createAgentKey({
context: ApmPluginRequestHandlerContext;
requestBody: {
name: string;
sourcemap?: boolean;
event?: boolean;
agentConfig?: boolean;
privileges: string[];
};
}) {
const { name, privileges } = requestBody;
const application = {
application: 'apm',
privileges,
resources: [resource],
};
// 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 },
body: {
application: userApplicationPrivileges,
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: ['-'],
},
],
application: [application],
},
}
);
if (!hasRequiredPrivileges) {
const missingPrivileges = Object.entries(application.apm['-'])
const missingPrivileges = Object.entries(
userApplicationPrivileges.apm[resource]
)
.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 {
.map((x) => x[0]);
const error = `${username} is missing the following requested privilege(s): ${missingPrivileges.join(
', '
)}.\
You might try with the superuser, or add the missing 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": ["*"]
"privileges": ${JSON.stringify(missingPrivileges)},
"resources": [${resource}]
}],
...
}`;
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: {
@ -114,13 +92,7 @@ export async function createAgentKey({
apm: {
cluster: [],
index: [],
applications: [
{
application: 'apm',
privileges,
resources: ['*'],
},
],
applications: [application],
},
},
};

View file

@ -19,6 +19,7 @@ export async function invalidateAgentKey({
{
body: {
ids: [id],
owner: true,
},
}
);

View file

@ -8,13 +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';
import { privilegesTypeRt } from '../../../common/privilege_type';
const agentKeysRoute = createApmServerRoute({
endpoint: 'GET /internal/apm/agent_keys',
@ -77,19 +77,13 @@ const invalidateAgentKeyRoute = createApmServerRoute({
});
const createAgentKeyRoute = createApmServerRoute({
endpoint: 'POST /apm/agent_keys',
endpoint: 'POST /api/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,
}),
]),
body: t.type({
name: t.string,
privileges: privilegesTypeRt,
}),
}),
handler: async (resources) => {
const { context, params } = resources;