diff --git a/docs/user/security/api-keys/index.asciidoc b/docs/user/security/api-keys/index.asciidoc index 3efb2cdeef6ca..c8b7d72248e5e 100644 --- a/docs/user/security/api-keys/index.asciidoc +++ b/docs/user/security/api-keys/index.asciidoc @@ -24,7 +24,7 @@ image:images/api-keys.png["API Keys UI"] === Security privileges You must have the `manage_security`, `manage_api_key`, or the `manage_own_api_key` -cluster privileges to use API keys in {kib}. To manage roles, open the main menu, then click +cluster privileges to use API keys in {kib}. API keys can also be seen in a readonly view with access to the page and the `read_security` cluster privilege. To manage roles, open the main menu, then click *Stack Management > Roles*, or use the <>. @@ -45,11 +45,20 @@ curl --location --request GET 'http://localhost:5601/api/security/role' \ --header 'kbn-xsrf: true' \ --header 'Authorization: ApiKey aVZlLUMzSUJuYndxdDJvN0k1bU46aGxlYUpNS2lTa2FKeVZua1FnY1VEdw==' \ + [IMPORTANT] ============================================================================ API keys are intended for programmatic access to {kib} and {es}. Do not use API keys to authenticate access via a web browser. ============================================================================ +[float] +[[udpate-api-key]] +=== Update an API key + +To update an API key, open the main menu, click *Stack Management > API Keys*, and then click on the name of the key. + +You can only update the `Restrict privileges` and `metadata` fields. + [float] [[view-api-keys]] === View and delete API keys @@ -62,6 +71,3 @@ created by which user in which realm. If you have only the `manage_own_api_key` permission, you see only a list of your own keys. You can delete API keys individually or in bulk. - -You cannot modify an API key. If you need additional privileges, -you must create a new key with the desired configuration and invalidate the old key. diff --git a/x-pack/plugins/security/common/model/api_key.ts b/x-pack/plugins/security/common/model/api_key.ts index ed622bf0dc87e..fcdd823b4b85e 100644 --- a/x-pack/plugins/security/common/model/api_key.ts +++ b/x-pack/plugins/security/common/model/api_key.ts @@ -16,6 +16,7 @@ export interface ApiKey { expiration: number; invalidated: boolean; metadata: Record; + role_descriptors?: Record; } export interface ApiKeyToInvalidate { diff --git a/x-pack/plugins/security/public/components/form_flyout.tsx b/x-pack/plugins/security/public/components/form_flyout.tsx index 020e5d7f31bf5..51ab56a11d225 100644 --- a/x-pack/plugins/security/public/components/form_flyout.tsx +++ b/x-pack/plugins/security/public/components/form_flyout.tsx @@ -34,6 +34,7 @@ export interface FormFlyoutProps extends Omit { submitButtonColor?: EuiButtonProps['color']; isLoading?: EuiButtonProps['isLoading']; isDisabled?: EuiButtonProps['isDisabled']; + isSubmitButtonHidden?: boolean; } export const FormFlyout: FunctionComponent = ({ @@ -44,6 +45,7 @@ export const FormFlyout: FunctionComponent = ({ onSubmit, isLoading, isDisabled, + isSubmitButtonHidden, children, initialFocus, ...rest @@ -80,18 +82,20 @@ export const FormFlyout: FunctionComponent = ({ /> - - - {submitButtonText} - - + {!isSubmitButtonHidden && ( + + + {submitButtonText} + + + )} diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_api_client.mock.ts b/x-pack/plugins/security/public/management/api_keys/api_keys_api_client.mock.ts index 1ba35a20a5e5f..a8f317800d8b0 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_api_client.mock.ts +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_api_client.mock.ts @@ -11,5 +11,6 @@ export const apiKeysAPIClientMock = { getApiKeys: jest.fn(), invalidateApiKeys: jest.fn(), createApiKey: jest.fn(), + updateApiKey: jest.fn(), }), }; diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_api_client.test.ts b/x-pack/plugins/security/public/management/api_keys/api_keys_api_client.test.ts index ea30710e2fac9..02b2024e609e0 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_api_client.test.ts +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_api_client.test.ts @@ -100,4 +100,20 @@ describe('APIKeysAPIClient', () => { body: JSON.stringify(mockAPIKeys), }); }); + + it('updateApiKey() queries correct endpoint', async () => { + const httpMock = httpServiceMock.createStartContract(); + + const mockResponse = Symbol('mockResponse'); + httpMock.put.mockResolvedValue(mockResponse); + + const apiClient = new APIKeysAPIClient(httpMock); + const mockApiKeyUpdate = { id: 'test_id', metadata: {}, roles_descriptor: {} }; + + await expect(apiClient.updateApiKey(mockApiKeyUpdate)).resolves.toBe(mockResponse); + expect(httpMock.put).toHaveBeenCalledTimes(1); + expect(httpMock.put).toHaveBeenCalledWith('/internal/security/api_key', { + body: JSON.stringify(mockApiKeyUpdate), + }); + }); }); diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_api_client.ts b/x-pack/plugins/security/public/management/api_keys/api_keys_api_client.ts index fd6c856425cd4..6095278d73864 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_api_client.ts +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_api_client.ts @@ -38,6 +38,16 @@ export interface CreateApiKeyResponse { api_key: string; } +export interface UpdateApiKeyRequest { + id: string; + role_descriptors?: ApiKeyRoleDescriptors; + metadata?: Record; +} + +export interface UpdateApiKeyResponse { + updated: boolean; +} + const apiKeysUrl = '/internal/security/api_key'; export class APIKeysAPIClient { @@ -62,4 +72,10 @@ export class APIKeysAPIClient { body: JSON.stringify(apiKey), }); } + + public async updateApiKey(apiKey: UpdateApiKeyRequest) { + return await this.http.put(apiKeysUrl, { + body: JSON.stringify(apiKey), + }); + } } diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/create_api_key_flyout.tsx b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_key_flyout.tsx similarity index 53% rename from x-pack/plugins/security/public/management/api_keys/api_keys_grid/create_api_key_flyout.tsx rename to x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_key_flyout.tsx index 7a36d9b3e0194..f0550d46544bb 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/create_api_key_flyout.tsx +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_key_flyout.tsx @@ -14,12 +14,15 @@ import { EuiForm, EuiFormFieldset, EuiFormRow, + EuiHealth, EuiIcon, EuiLoadingContent, EuiSpacer, EuiSwitch, EuiText, + EuiToolTip, } from '@elastic/eui'; +import moment from 'moment-timezone'; import type { FunctionComponent } from 'react'; import React, { useEffect } from 'react'; import useAsyncFn from 'react-use/lib/useAsyncFn'; @@ -28,7 +31,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { CodeEditorField, useKibana } from '@kbn/kibana-react-plugin/public'; -import type { ApiKeyRoleDescriptors } from '../../../../common/model'; +import type { ApiKey, ApiKeyRoleDescriptors, AuthenticatedUser } from '../../../../common/model'; import { DocLink } from '../../../components/doc_link'; import type { FormFlyoutProps } from '../../../components/form_flyout'; import { FormFlyout } from '../../../components/form_flyout'; @@ -38,7 +41,12 @@ import type { ValidationErrors } from '../../../components/use_form'; import { useInitialFocus } from '../../../components/use_initial_focus'; import { RolesAPIClient } from '../../roles/roles_api_client'; import { APIKeysAPIClient } from '../api_keys_api_client'; -import type { CreateApiKeyRequest, CreateApiKeyResponse } from '../api_keys_api_client'; +import type { + CreateApiKeyRequest, + CreateApiKeyResponse, + UpdateApiKeyRequest, + UpdateApiKeyResponse, +} from '../api_keys_api_client'; export interface ApiKeyFormValues { name: string; @@ -50,38 +58,82 @@ export interface ApiKeyFormValues { metadata: string; } -export interface CreateApiKeyFlyoutProps { +export interface ApiKeyFlyoutProps { defaultValues?: ApiKeyFormValues; - onSuccess?: (apiKey: CreateApiKeyResponse) => void; + onSuccess?: ( + createApiKeyResponse: CreateApiKeyResponse | undefined, + updateApiKeyResponse: UpdateApiKeyResponse | undefined + ) => void; onCancel: FormFlyoutProps['onCancel']; + apiKey?: ApiKey; + readonly?: boolean; } const defaultDefaultValues: ApiKeyFormValues = { name: '', - expiration: '', customExpiration: false, - customPrivileges: false, + expiration: '', includeMetadata: false, - role_descriptors: '{}', metadata: '{}', + customPrivileges: false, + role_descriptors: '{}', }; -export const CreateApiKeyFlyout: FunctionComponent = ({ +export const ApiKeyFlyout: FunctionComponent = ({ onSuccess, onCancel, - defaultValues = defaultDefaultValues, + apiKey, + readonly = false, }) => { - const { services } = useKibana(); + let formTitle = 'Create API Key'; + let inProgressButtonText = 'Creating API Key…'; + let errorTitle = 'create API key'; + const { value: currentUser, loading: isLoadingCurrentUser } = useCurrentUser(); + + let canEditApiKey = false; + + let defaultValues = defaultDefaultValues; + + if (apiKey) { + defaultValues = retrieveValuesFromApiKeyToDefaultFlyout(apiKey); + + canEditApiKey = isEditable(currentUser, apiKey); + + if (readonly || !canEditApiKey) { + formTitle = 'API key details'; + inProgressButtonText = ''; // This won't be seen since Submit will be disabled + errorTitle = ''; + } else { + formTitle = 'Update API Key'; + inProgressButtonText = 'Updating API Key…'; + errorTitle = 'update API key'; + } + } + + const { services } = useKibana(); + const [{ value: roles, loading: isLoadingRoles }, getRoles] = useAsyncFn( () => new RolesAPIClient(services.http!).getRoles(), [services.http] ); + const [form, eventHandlers] = useForm({ onSubmit: async (values) => { try { - const apiKey = await new APIKeysAPIClient(services.http!).createApiKey(mapValues(values)); - onSuccess?.(apiKey); + if (apiKey) { + const updateApiKeyResponse = await new APIKeysAPIClient(services.http!).updateApiKey( + mapUpdateApiKeyValues(apiKey.id, values) + ); + + onSuccess?.(undefined, updateApiKeyResponse); + } else { + const createApiKeyResponse = await new APIKeysAPIClient(services.http!).createApiKey( + mapCreateApiKeyValues(values) + ); + + onSuccess?.(createApiKeyResponse, undefined); + } } catch (error) { throw error; } @@ -89,6 +141,7 @@ export const CreateApiKeyFlyout: FunctionComponent = ({ validate, defaultValues, }); + const isLoading = isLoadingCurrentUser || isLoadingRoles; useEffect(() => { @@ -107,7 +160,8 @@ export const CreateApiKeyFlyout: FunctionComponent = ({ }, {} ); - if (!form.touched.role_descriptors) { + + if (!form.touched.role_descriptors && !apiKey) { form.setValue('role_descriptors', JSON.stringify(userPermissions, null, 2)); } } @@ -117,28 +171,33 @@ export const CreateApiKeyFlyout: FunctionComponent = ({ return ( {form.submitError && ( <> @@ -178,8 +237,9 @@ export const CreateApiKeyFlyout: FunctionComponent = ({ whiteSpace: 'nowrap', textOverflow: 'ellipsis', }} + data-test-subj="apiKeyFlyoutUsername" > - {currentUser?.username} + {apiKey ? apiKey.username : currentUser?.username} @@ -187,12 +247,9 @@ export const CreateApiKeyFlyout: FunctionComponent = ({ @@ -201,34 +258,51 @@ export const CreateApiKeyFlyout: FunctionComponent = ({ defaultValue={form.values.name} isInvalid={form.touched.name && !!form.errors.name} inputRef={firstFieldRef} + disabled={!!apiKey || readonly} fullWidth data-test-subj="apiKeyNameInput" /> + {!!apiKey && ( + <> + + + {determineReadonlyExpiration(form.values?.expiration)} + + + )} + form.setValue('customPrivileges', e.target.checked)} + disabled={readonly || (apiKey && !canEditApiKey)} /> {form.values.customPrivileges && ( <> @@ -241,6 +315,7 @@ export const CreateApiKeyFlyout: FunctionComponent = ({ onChange={(value) => form.setValue('role_descriptors', value)} languageId="xjson" height={200} + options={{ readOnly: readonly || (apiKey && !canEditApiKey) }} /> @@ -248,75 +323,84 @@ export const CreateApiKeyFlyout: FunctionComponent = ({ )} - - - form.setValue('customExpiration', e.target.checked)} - data-test-subj="apiKeyCustomExpirationSwitch" - /> - {form.values.customExpiration && ( - <> - - + + + - - - - - )} - + checked={form.values.customExpiration} + onChange={(e) => form.setValue('customExpiration', e.target.checked)} + disabled={readonly || !!apiKey} + data-test-subj="apiKeyCustomExpirationSwitch" + /> + {form.values.customExpiration && ( + <> + + + + + + + )} + + + )} form.setValue('includeMetadata', e.target.checked)} /> {form.values.includeMetadata && ( <> @@ -329,6 +413,7 @@ export const CreateApiKeyFlyout: FunctionComponent = ({ onChange={(value) => form.setValue('metadata', value)} languageId="xjson" height={200} + options={{ readOnly: readonly || (apiKey && !canEditApiKey) }} /> @@ -348,7 +433,7 @@ export function validate(values: ApiKeyFormValues) { const errors: ValidationErrors = {}; if (!values.name) { - errors.name = i18n.translate('xpack.security.management.apiKeys.createApiKey.nameRequired', { + errors.name = i18n.translate('xpack.security.management.apiKeys.apiKeyFlyout.nameRequired', { defaultMessage: 'Enter a name.', }); } @@ -357,7 +442,7 @@ export function validate(values: ApiKeyFormValues) { const parsedExpiration = parseFloat(values.expiration); if (isNaN(parsedExpiration) || parsedExpiration <= 0) { errors.expiration = i18n.translate( - 'xpack.security.management.apiKeys.createApiKey.expirationRequired', + 'xpack.security.management.apiKeys.apiKeyFlyout.expirationRequired', { defaultMessage: 'Enter a valid duration or disable this option.', } @@ -368,7 +453,7 @@ export function validate(values: ApiKeyFormValues) { if (values.customPrivileges) { if (!values.role_descriptors) { errors.role_descriptors = i18n.translate( - 'xpack.security.management.apiKeys.createApiKey.roleDescriptorsRequired', + 'xpack.security.management.apiKeys.apiKeyFlyout.roleDescriptorsRequired', { defaultMessage: 'Enter role descriptors or disable this option.', } @@ -378,7 +463,7 @@ export function validate(values: ApiKeyFormValues) { JSON.parse(values.role_descriptors); } catch (e) { errors.role_descriptors = i18n.translate( - 'xpack.security.management.apiKeys.createApiKey.invalidJsonError', + 'xpack.security.management.apiKeys.apiKeyFlyout.invalidJsonError', { defaultMessage: 'Enter valid JSON.', } @@ -390,7 +475,7 @@ export function validate(values: ApiKeyFormValues) { if (values.includeMetadata) { if (!values.metadata) { errors.metadata = i18n.translate( - 'xpack.security.management.apiKeys.createApiKey.metadataRequired', + 'xpack.security.management.apiKeys.apiKeyFlyout.metadataRequired', { defaultMessage: 'Enter metadata or disable this option.', } @@ -400,7 +485,7 @@ export function validate(values: ApiKeyFormValues) { JSON.parse(values.metadata); } catch (e) { errors.metadata = i18n.translate( - 'xpack.security.management.apiKeys.createApiKey.invalidJsonError', + 'xpack.security.management.apiKeys.apiKeyFlyout.invalidJsonError', { defaultMessage: 'Enter valid JSON.', } @@ -412,7 +497,7 @@ export function validate(values: ApiKeyFormValues) { return errors; } -export function mapValues(values: ApiKeyFormValues): CreateApiKeyRequest { +export function mapCreateApiKeyValues(values: ApiKeyFormValues): CreateApiKeyRequest { return { name: values.name, expiration: values.customExpiration && values.expiration ? `${values.expiration}d` : undefined, @@ -423,3 +508,86 @@ export function mapValues(values: ApiKeyFormValues): CreateApiKeyRequest { metadata: values.includeMetadata && values.metadata ? JSON.parse(values.metadata) : undefined, }; } + +export function mapUpdateApiKeyValues(id: string, values: ApiKeyFormValues): UpdateApiKeyRequest { + return { + id, + role_descriptors: + values.customPrivileges && values.role_descriptors + ? JSON.parse(values.role_descriptors) + : undefined, + metadata: values.includeMetadata && values.metadata ? JSON.parse(values.metadata) : {}, + }; +} + +function isEditable(currentUser: AuthenticatedUser | undefined, apiKey: ApiKey): boolean { + let result = false; + const isApiKeyOwner = currentUser && currentUser.username === apiKey.username; + const isNotExpired = !apiKey.expiration || moment(apiKey.expiration).isAfter(); + + if (isApiKeyOwner && isNotExpired) { + result = true; + } + + return result; +} + +function determineReadonlyExpiration(expiration?: string) { + const DATE_FORMAT = 'MMMM Do YYYY HH:mm:ss'; + + if (!expiration) { + return ( + + + + ); + } + + const expirationInt = parseInt(expiration, 10); + + if (Date.now() > expirationInt) { + return ( + + + + ); + } + + return ( + + + + + + ); +} + +function retrieveValuesFromApiKeyToDefaultFlyout(apiKey: ApiKey): ApiKeyFormValues { + // Collect data from the selected API key to pre-populate the form + const doesMetadataExist = Object.keys(apiKey.metadata).length > 0; + const doCustomPrivilegesExist = Object.keys(apiKey.role_descriptors ?? 0).length > 0; + + return { + name: apiKey.name, + customExpiration: !!apiKey.expiration, + expiration: !!apiKey.expiration ? apiKey.expiration.toString() : '', + includeMetadata: doesMetadataExist, + metadata: doesMetadataExist ? JSON.stringify(apiKey.metadata, null, 2) : '{}', + customPrivileges: doCustomPrivilegesExist, + role_descriptors: doCustomPrivilegesExist + ? JSON.stringify(apiKey.role_descriptors, null, 2) + : '{}', + }; +} diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_grid_page.test.tsx b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_grid_page.test.tsx index 73e3c3253074e..f442c464ff0b2 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_grid_page.test.tsx +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_grid_page.test.tsx @@ -77,7 +77,7 @@ describe('APIKeysGridPage', () => { authc.getCurrentUser.mockResolvedValue( mockAuthenticatedUser({ - username: 'jdoe', + username: 'elastic', full_name: '', email: '', enabled: true, @@ -96,7 +96,7 @@ describe('APIKeysGridPage', () => { }, }; - const { findByText, queryByTestId } = render( + const { findByText, queryByTestId, getByText } = render( { ); expect(await queryByTestId('apiKeysCreateTableButton')).toBeNull(); + expect(await findByText(/Loading API keys/)).not.toBeInTheDocument(); + await findByText(/first-api-key/); - await findByText(/second-api-key/); + + const secondKey = getByText(/second-api-key/).closest('td'); + const secondKeyEuiLink = secondKey!.querySelector('button'); + expect(secondKeyEuiLink).not.toBeNull(); + expect(secondKeyEuiLink!.getAttribute('data-test-subj')).toBe('roleRowName-second-api-key'); }); afterAll(() => { @@ -247,37 +253,5 @@ describe('APIKeysGridPage', () => { expect(await findByText('You do not have permission to create API keys')).toBeInTheDocument(); expect(queryByText('Create API key')).toBeNull(); }); - - it('should not display table `Create Button` nor `Delete` icons column', async () => { - const history = createMemoryHistory({ initialEntries: ['/'] }); - - coreStart.application.capabilities = { - ...coreStart.application.capabilities, - api_keys: { - save: false, - }, - }; - - const { findByText, queryByText, queryAllByText } = await render( - - - - ); - - expect(await findByText(/Loading API keys/)).not.toBeInTheDocument(); - expect( - await findByText('You only have permission to view your own API keys.') - ).toBeInTheDocument(); - expect( - await findByText('View your API keys. An API key sends requests on your behalf.') - ).toBeInTheDocument(); - expect(queryByText('Create API key')).toBeNull(); - expect(queryAllByText('Delete').length).toBe(0); - }); }); }); diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_grid_page.tsx b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_grid_page.tsx index aa94f4f147789..0993e88e7ab16 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_grid_page.tsx +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_grid_page.tsx @@ -15,6 +15,7 @@ import { EuiHealth, EuiIcon, EuiInMemoryTable, + EuiLink, EuiSpacer, EuiText, EuiToolTip, @@ -36,9 +37,13 @@ import type { PublicMethodsOf } from '@kbn/utility-types'; import type { ApiKey, ApiKeyToInvalidate } from '../../../../common/model'; import { Breadcrumb } from '../../../components/breadcrumb'; import { SelectableTokenField } from '../../../components/token_field'; -import type { APIKeysAPIClient, CreateApiKeyResponse } from '../api_keys_api_client'; +import type { + APIKeysAPIClient, + CreateApiKeyResponse, + UpdateApiKeyResponse, +} from '../api_keys_api_client'; +import { ApiKeyFlyout } from './api_key_flyout'; import { ApiKeysEmptyPrompt } from './api_keys_empty_prompt'; -import { CreateApiKeyFlyout } from './create_api_key_flyout'; import type { InvalidateApiKeys } from './invalidate_provider'; import { InvalidateProvider } from './invalidate_provider'; import { NotEnabled } from './not_enabled'; @@ -61,6 +66,8 @@ interface State { selectedItems: ApiKey[]; error: any; createdApiKey?: CreateApiKeyResponse; + selectedApiKey?: ApiKey; + isUpdateFlyoutVisible: boolean; } const DATE_FORMAT = 'MMMM Do YYYY HH:mm:ss'; @@ -81,6 +88,8 @@ export class APIKeysGridPage extends Component { apiKeys: undefined, selectedItems: [], error: undefined, + selectedApiKey: undefined, + isUpdateFlyoutVisible: false, }; } @@ -90,6 +99,7 @@ export class APIKeysGridPage extends Component { public render() { return ( + // Flyout to create new ApiKey <> { })} href="/create" > - { + { this.props.history.push({ pathname: '/' }); + this.reloadApiKeys(); - this.setState({ createdApiKey: apiKey }); + + this.setState({ + createdApiKey: createApiKeyResponse, + }); + }} + onCancel={() => { + this.props.history.push({ pathname: '/' }); + this.setState({ selectedApiKey: undefined }); }} - onCancel={() => this.props.history.push({ pathname: '/' })} /> + + { + // Flyout to update or view ApiKey + this.state.isUpdateFlyoutVisible && ( + { + this.reloadApiKeys(); + this.displayUpdatedApiKeyToast(updateApiKeyResponse); + this.setState({ + selectedApiKey: undefined, + isUpdateFlyoutVisible: false, + }); + }} + onCancel={() => { + this.setState({ selectedApiKey: undefined, isUpdateFlyoutVisible: false }); + }} + apiKey={this.state.selectedApiKey} + readonly={this.props.readOnly} + /> + ) + } {this.renderContent()} ); @@ -162,6 +200,7 @@ export class APIKeysGridPage extends Component { fill iconType="plusInCircleFilled" data-test-subj="apiKeysCreatePromptButton" + href={'/'} > { defaultMessage: 'Name', }), sortable: true, + render: (name: string, recordAP: ApiKey) => { + return ( + + { + this.setState({ selectedApiKey: recordAP, isUpdateFlyoutVisible: true }); + }} + > + {name} + + + ); + }, }, ]); @@ -657,21 +710,21 @@ export class APIKeysGridPage extends Component { return ( ); } else if (readOnly) { return ( ); } else { return ( ); } @@ -694,4 +747,16 @@ export class APIKeysGridPage extends Component { ); } } + + private displayUpdatedApiKeyToast(updateApiKeyResponse?: UpdateApiKeyResponse) { + if (updateApiKeyResponse) { + this.props.notifications.toasts.addSuccess({ + title: i18n.translate('xpack.security.management.apiKeys.updateSuccessMessage', { + defaultMessage: "Updated API key '{name}'", + values: { name: this.state.selectedApiKey?.name }, + }), + 'data-test-subj': 'updateApiKeySuccessToast', + }); + } + } } diff --git a/x-pack/plugins/security/server/authentication/api_keys/api_keys.mock.ts b/x-pack/plugins/security/server/authentication/api_keys/api_keys.mock.ts index 0c18119ad2051..697f1bf355a70 100644 --- a/x-pack/plugins/security/server/authentication/api_keys/api_keys.mock.ts +++ b/x-pack/plugins/security/server/authentication/api_keys/api_keys.mock.ts @@ -13,6 +13,7 @@ export const apiKeysMock = { create: (): jest.Mocked> => ({ areAPIKeysEnabled: jest.fn(), create: jest.fn(), + update: jest.fn(), grantAsInternalUser: jest.fn(), validate: jest.fn(), invalidate: jest.fn(), diff --git a/x-pack/plugins/security/server/authentication/api_keys/api_keys.test.ts b/x-pack/plugins/security/server/authentication/api_keys/api_keys.test.ts index fc89b272c90ae..72a5359d47c0b 100644 --- a/x-pack/plugins/security/server/authentication/api_keys/api_keys.test.ts +++ b/x-pack/plugins/security/server/authentication/api_keys/api_keys.test.ts @@ -13,6 +13,7 @@ import { httpServerMock, loggingSystemMock, } from '@kbn/core/server/mocks'; +import type { Logger } from '@kbn/logging'; import { ALL_SPACES_ID } from '../../../common/constants'; import type { SecurityLicense } from '../../../common/licensing'; @@ -29,6 +30,7 @@ describe('API Keys', () => { typeof elasticsearchServiceMock.createScopedClusterClient >; let mockLicense: jest.Mocked; + let logger: Logger; beforeEach(() => { mockValidateKibanaPrivileges.mockReset().mockReturnValue({ validationErrors: [] }); @@ -40,9 +42,11 @@ describe('API Keys', () => { mockLicense = licenseMock.create(); mockLicense.isEnabled.mockReturnValue(true); + logger = loggingSystemMock.create().get('api-keys'); + apiKeys = new APIKeys({ clusterClient: mockClusterClient, - logger: loggingSystemMock.create().get('api-keys'), + logger, license: mockLicense, applicationName: 'kibana-.kibana', kibanaFeatures: [], @@ -205,6 +209,99 @@ describe('API Keys', () => { }); }); + describe('update()', () => { + it('returns null when security feature is disabled', async () => { + mockLicense.isEnabled.mockReturnValue(false); + const result = await apiKeys.update(httpServerMock.createKibanaRequest(), { + id: 'test_id', + metadata: {}, + role_descriptors: {}, + }); + expect(result).toBeNull(); + expect(mockValidateKibanaPrivileges).not.toHaveBeenCalled(); + expect(mockScopedClusterClient.asCurrentUser.security.updateApiKey).not.toHaveBeenCalled(); + }); + + it('throws an error when kibana privilege validation fails', async () => { + mockLicense.isEnabled.mockReturnValue(true); + mockValidateKibanaPrivileges + .mockReturnValueOnce({ validationErrors: ['error1'] }) // for descriptor1 + .mockReturnValueOnce({ validationErrors: [] }) // for descriptor2 + .mockReturnValueOnce({ validationErrors: ['error2'] }); // for descriptor3 + + await expect( + apiKeys.update(httpServerMock.createKibanaRequest(), { + id: 'test_id', + kibana_role_descriptors: { + descriptor1: { elasticsearch: {}, kibana: [] }, + descriptor2: { elasticsearch: {}, kibana: [] }, + descriptor3: { elasticsearch: {}, kibana: [] }, + }, + }) + ).rejects.toEqual( + // The validation errors from descriptor1 and descriptor3 are concatenated into the final error message + new Error('API key cannot be updated due to validation errors: ["error1","error2"]') + ); + + expect(mockValidateKibanaPrivileges).toHaveBeenCalledTimes(3); + expect(mockScopedClusterClient.asCurrentUser.security.updateApiKey).not.toHaveBeenCalled(); + }); + + it('calls `updateApiKey` with proper parameters and receives `updated: true` in the response', async () => { + mockLicense.isEnabled.mockReturnValue(true); + + mockScopedClusterClient.asCurrentUser.security.updateApiKey.mockResponseOnce({ + updated: true, + }); + + const result = await apiKeys.update(httpServerMock.createKibanaRequest(), { + id: 'test_id', + role_descriptors: { foo: true }, + metadata: {}, + }); + + expect(result).toEqual({ + updated: true, + }); + + expect(logger.debug).toHaveBeenNthCalledWith(1, 'Trying to edit an API key'); + expect(logger.debug).toHaveBeenNthCalledWith(2, 'API key was updated successfully'); + expect(mockValidateKibanaPrivileges).not.toHaveBeenCalled(); // this is only called if kibana_role_descriptors is defined + expect(mockScopedClusterClient.asCurrentUser.security.updateApiKey).toHaveBeenCalledWith({ + id: 'test_id', + role_descriptors: { foo: true }, + metadata: {}, + }); + }); + + it('calls `updateApiKey` with proper parameters and receives `updated: false` in the response', async () => { + mockLicense.isEnabled.mockReturnValue(true); + + mockScopedClusterClient.asCurrentUser.security.updateApiKey.mockResponseOnce({ + updated: false, + }); + + const result = await apiKeys.update(httpServerMock.createKibanaRequest(), { + id: 'test_id', + role_descriptors: { foo: true }, + metadata: {}, + }); + + expect(result).toEqual({ + updated: false, + }); + + expect(logger.debug).toHaveBeenNthCalledWith(1, 'Trying to edit an API key'); + expect(logger.debug).toHaveBeenNthCalledWith(2, 'There were no updates to make for API key'); + expect(mockValidateKibanaPrivileges).not.toHaveBeenCalled(); // this is only called if kibana_role_descriptors is defined + expect(mockScopedClusterClient.asCurrentUser.security.updateApiKey).toHaveBeenCalledWith({ + id: 'test_id', + role_descriptors: { foo: true }, + metadata: {}, + }); + }); + }); + describe('grantAsInternalUser()', () => { it('returns null when security feature is disabled', async () => { mockLicense.isEnabled.mockReturnValue(false); @@ -639,5 +736,54 @@ describe('API Keys', () => { }, }); }); + + it('updates api key with application privileges', async () => { + mockLicense.isEnabled.mockReturnValue(true); + + mockScopedClusterClient.asCurrentUser.security.updateApiKey.mockResponseOnce({ + updated: true, + }); + const result = await apiKeys.update(httpServerMock.createKibanaRequest(), { + id: 'test_id', + kibana_role_descriptors: { + synthetics_writer: { + elasticsearch: { cluster: ['manage'], indices: [], run_as: [] }, + kibana: [ + { + base: [], + spaces: [ALL_SPACES_ID], + feature: { + uptime: ['all'], + }, + }, + ], + }, + }, + metadata: {}, + }); + + expect(result).toEqual({ + updated: true, + }); + + expect(mockScopedClusterClient.asCurrentUser.security.updateApiKey).toHaveBeenCalledWith({ + id: 'test_id', + role_descriptors: { + synthetics_writer: { + applications: [ + { + application: 'kibana-.kibana', + privileges: ['feature_uptime.all'], + resources: ['*'], + }, + ], + cluster: ['manage'], + indices: [], + run_as: [], + }, + }, + metadata: {}, + }); + }); }); }); diff --git a/x-pack/plugins/security/server/authentication/api_keys/api_keys.ts b/x-pack/plugins/security/server/authentication/api_keys/api_keys.ts index 92ae11762d1ce..854524df7d596 100644 --- a/x-pack/plugins/security/server/authentication/api_keys/api_keys.ts +++ b/x-pack/plugins/security/server/authentication/api_keys/api_keys.ts @@ -43,6 +43,16 @@ interface BaseCreateAPIKeyParams { >; } +interface BaseUpdateAPIKeyParams { + id: string; + metadata?: Record; + role_descriptors: Record; + kibana_role_descriptors: Record< + string, + { elasticsearch: ElasticsearchPrivilegesType; kibana: KibanaPrivilegesType } + >; +} + /** * Represents the params for creating an API key */ @@ -51,6 +61,14 @@ export type CreateAPIKeyParams = OneOf< 'role_descriptors' | 'kibana_role_descriptors' >; +/** + * Represents the params for updating an API key + */ +export type UpdateAPIKeyParams = OneOf< + BaseUpdateAPIKeyParams, + 'role_descriptors' | 'kibana_role_descriptors' +>; + type GrantAPIKeyParams = | { api_key: CreateAPIKeyParams; @@ -96,6 +114,17 @@ export interface CreateAPIKeyResult { api_key: string; } +/** + * The return value when update an API key in Elasticsearch. The value returned by this API + * can is contains a `updated` boolean that corresponds to whether or not the API key was updated + */ +export interface UpdateAPIKeyResult { + /** + * Boolean represented if the API key was updated in ES + */ + updated: boolean; +} + export interface GrantAPIKeyResult { /** * Unique id for this API key @@ -226,7 +255,7 @@ export class APIKeys { const { expiration, metadata, name } = createParams; - const roleDescriptors = this.parseRoleDescriptorsWithKibanaPrivileges(createParams); + const roleDescriptors = this.parseRoleDescriptorsWithKibanaPrivileges(createParams, false); this.logger.debug('Trying to create an API key'); @@ -244,6 +273,51 @@ export class APIKeys { return result; } + /** + * Attempts update an API key with the provided 'role_descriptors' and 'metadata' + * + * Returns `updated`, `true` if the update was successful, `false` if there was nothing to update + * + * @param request Request instance. + * @param updateParams The params to edit an API key + */ + async update( + request: KibanaRequest, + updateParams: UpdateAPIKeyParams + ): Promise { + if (!this.license.isEnabled()) { + return null; + } + + const { id, metadata } = updateParams; + + const roleDescriptors = this.parseRoleDescriptorsWithKibanaPrivileges(updateParams, true); + + this.logger.debug('Trying to edit an API key'); + + // User needs `manage_api_key` privilege to use this API + let result: UpdateAPIKeyResult; + + try { + result = await this.clusterClient.asScoped(request).asCurrentUser.security.updateApiKey({ + id, + role_descriptors: roleDescriptors, + metadata, + }); + + if (result.updated) { + this.logger.debug('API key was updated successfully'); + } else { + this.logger.debug('There were no updates to make for API key'); + } + } catch (e) { + this.logger.error(`Failed to update API key: ${e.message}`); + throw e; + } + + return result; + } + /** * Tries to grant an API key for the current user. * @param request Request instance. @@ -263,7 +337,7 @@ export class APIKeys { } const { expiration, metadata, name } = createParams; - const roleDescriptors = this.parseRoleDescriptorsWithKibanaPrivileges(createParams); + const roleDescriptors = this.parseRoleDescriptorsWithKibanaPrivileges(createParams, false); const params = this.getGrantParams( { expiration, metadata, name, role_descriptors: roleDescriptors }, @@ -404,14 +478,23 @@ export class APIKeys { throw new Error(`Unsupported scheme "${authorizationHeader.scheme}" for granting API Key`); } - private parseRoleDescriptorsWithKibanaPrivileges(createParams: CreateAPIKeyParams) { - if (createParams.role_descriptors) { - return createParams.role_descriptors; + private parseRoleDescriptorsWithKibanaPrivileges( + params: Partial<{ + kibana_role_descriptors: Record< + string, + { elasticsearch: ElasticsearchPrivilegesType; kibana: KibanaPrivilegesType } + >; + role_descriptors: Record; + }>, + isEdit: boolean + ) { + if (params.role_descriptors) { + return params.role_descriptors; } const roleDescriptors = Object.create(null); - const { kibana_role_descriptors: kibanaRoleDescriptors } = createParams; + const { kibana_role_descriptors: kibanaRoleDescriptors } = params; const allValidationErrors: string[] = []; if (kibanaRoleDescriptors) { @@ -435,9 +518,19 @@ export class APIKeys { }); } if (allValidationErrors.length) { - throw new CreateApiKeyValidationError( - `API key cannot be created due to validation errors: ${JSON.stringify(allValidationErrors)}` - ); + if (isEdit) { + throw new UpdateApiKeyValidationError( + `API key cannot be updated due to validation errors: ${JSON.stringify( + allValidationErrors + )}` + ); + } else { + throw new CreateApiKeyValidationError( + `API key cannot be created due to validation errors: ${JSON.stringify( + allValidationErrors + )}` + ); + } } return roleDescriptors; @@ -449,3 +542,9 @@ export class CreateApiKeyValidationError extends Error { super(message); } } + +export class UpdateApiKeyValidationError extends Error { + constructor(message: string) { + super(message); + } +} diff --git a/x-pack/plugins/security/server/authentication/authentication_service.ts b/x-pack/plugins/security/server/authentication/authentication_service.ts index d0ffb92bf6c47..881bf0741e361 100644 --- a/x-pack/plugins/security/server/authentication/authentication_service.ts +++ b/x-pack/plugins/security/server/authentication/authentication_service.ts @@ -62,6 +62,7 @@ export interface InternalAuthenticationServiceStart extends AuthenticationServic APIKeys, | 'areAPIKeysEnabled' | 'create' + | 'update' | 'invalidate' | 'validate' | 'grantAsInternalUser' @@ -354,6 +355,7 @@ export class AuthenticationService { apiKeys: { areAPIKeysEnabled: apiKeys.areAPIKeysEnabled.bind(apiKeys), create: apiKeys.create.bind(apiKeys), + update: apiKeys.update.bind(apiKeys), grantAsInternalUser: apiKeys.grantAsInternalUser.bind(apiKeys), invalidate: apiKeys.invalidate.bind(apiKeys), validate: apiKeys.validate.bind(apiKeys), diff --git a/x-pack/plugins/security/server/plugin.test.ts b/x-pack/plugins/security/server/plugin.test.ts index 0871db0bbde72..de90d3e336db0 100644 --- a/x-pack/plugins/security/server/plugin.test.ts +++ b/x-pack/plugins/security/server/plugin.test.ts @@ -141,6 +141,7 @@ describe('Security Plugin', () => { "grantAsInternalUser": [Function], "invalidate": [Function], "invalidateAsInternalUser": [Function], + "update": [Function], "validate": [Function], }, "getCurrentUser": [Function], diff --git a/x-pack/plugins/security/server/routes/api_keys/index.ts b/x-pack/plugins/security/server/routes/api_keys/index.ts index ba97b451d6452..d815fb749fb4d 100644 --- a/x-pack/plugins/security/server/routes/api_keys/index.ts +++ b/x-pack/plugins/security/server/routes/api_keys/index.ts @@ -11,11 +11,13 @@ import { defineEnabledApiKeysRoutes } from './enabled'; import { defineGetApiKeysRoutes } from './get'; import { defineInvalidateApiKeysRoutes } from './invalidate'; import { defineCheckPrivilegesRoutes } from './privileges'; +import { defineUpdateApiKeyRoutes } from './update'; export function defineApiKeysRoutes(params: RouteDefinitionParams) { defineEnabledApiKeysRoutes(params); defineGetApiKeysRoutes(params); defineCreateApiKeyRoutes(params); + defineUpdateApiKeyRoutes(params); defineCheckPrivilegesRoutes(params); defineInvalidateApiKeysRoutes(params); } diff --git a/x-pack/plugins/security/server/routes/api_keys/update.test.ts b/x-pack/plugins/security/server/routes/api_keys/update.test.ts new file mode 100644 index 0000000000000..b00f8cfdfb211 --- /dev/null +++ b/x-pack/plugins/security/server/routes/api_keys/update.test.ts @@ -0,0 +1,135 @@ +/* + * 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 type { RequestHandler } from '@kbn/core/server'; +import { kibanaResponseFactory } from '@kbn/core/server'; +import { coreMock, httpServerMock } from '@kbn/core/server/mocks'; +import type { DeeplyMockedKeys } from '@kbn/utility-types-jest'; + +import type { InternalAuthenticationServiceStart } from '../../authentication'; +import { authenticationServiceMock } from '../../authentication/authentication_service.mock'; +import { routeDefinitionParamsMock } from '../index.mock'; +import { defineUpdateApiKeyRoutes } from './update'; + +describe('Update API Key route', () => { + function getMockContext( + licenseCheckResult: { state: string; message?: string } = { state: 'valid' } + ) { + return coreMock.createCustomRequestHandlerContext({ + licensing: { license: { check: jest.fn().mockReturnValue(licenseCheckResult) } }, + }); + } + + let routeHandler: RequestHandler; + + let authc: DeeplyMockedKeys; + + beforeEach(() => { + authc = authenticationServiceMock.createStart(); + + const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); + + mockRouteDefinitionParams.getAuthenticationService.mockReturnValue(authc); + + defineUpdateApiKeyRoutes(mockRouteDefinitionParams); + + const [, apiKeyRouteHandler] = mockRouteDefinitionParams.router.put.mock.calls.find( + ([{ path }]) => path === '/internal/security/api_key' + )!; + routeHandler = apiKeyRouteHandler; + }); + + describe('failure', () => { + it('returns result of license checker', async () => { + const mockContext = getMockContext({ state: 'invalid', message: 'test forbidden message' }); + + const response = await routeHandler( + mockContext, + httpServerMock.createKibanaRequest(), + kibanaResponseFactory + ); + + expect(response.status).toBe(403); + expect(response.payload).toEqual({ message: 'test forbidden message' }); + expect((await mockContext.licensing).license.check).toHaveBeenCalledWith('security', 'basic'); + }); + + it('returns error from cluster client', async () => { + const error = Boom.notAcceptable('test not acceptable message'); + authc.apiKeys.update.mockRejectedValue(error); + + const response = await routeHandler( + getMockContext(), + httpServerMock.createKibanaRequest(), + kibanaResponseFactory + ); + + expect(response.status).toBe(406); + expect(response.payload).toEqual(error); + }); + }); + + describe('success', () => { + it('allows an API Key to be updated', async () => { + authc.apiKeys.update.mockResolvedValue({ + updated: true, + }); + + const payload = { + id: 'test_id', + role_descriptors: { + role_1: {}, + }, + metadata: { + foo: 'bar', + }, + }; + + const request = httpServerMock.createKibanaRequest({ + body: { + ...payload, + }, + }); + + const response = await routeHandler(getMockContext(), request, kibanaResponseFactory); + + expect(authc.apiKeys.update).toHaveBeenCalledWith(request, payload); + expect(response.status).toBe(200); + expect(response.payload).toEqual({ + updated: true, + }); + }); + + it('returns a message if API Keys are disabled', async () => { + authc.apiKeys.update.mockResolvedValue(null); + + const payload = { + id: 'test_id', + metadata: {}, + role_descriptors: { + role_1: {}, + }, + }; + + const request = httpServerMock.createKibanaRequest({ + body: { + ...payload, + }, + }); + + const response = await routeHandler(getMockContext(), request, kibanaResponseFactory); + + expect(authc.apiKeys.update).toHaveBeenCalledWith(request, payload); + expect(response.status).toBe(400); + expect(response.payload).toEqual({ + message: 'API Keys are not available', + }); + }); + }); +}); diff --git a/x-pack/plugins/security/server/routes/api_keys/update.ts b/x-pack/plugins/security/server/routes/api_keys/update.ts new file mode 100644 index 0000000000000..a7ff22fe58cc2 --- /dev/null +++ b/x-pack/plugins/security/server/routes/api_keys/update.ts @@ -0,0 +1,80 @@ +/* + * 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 { schema } from '@kbn/config-schema'; + +import type { RouteDefinitionParams } from '..'; +import type { UpdateAPIKeyResult } from '../../authentication/api_keys/api_keys'; +import { UpdateApiKeyValidationError } from '../../authentication/api_keys/api_keys'; +import { wrapIntoCustomErrorResponse } from '../../errors'; +import { elasticsearchRoleSchema, getKibanaRoleSchema } from '../../lib'; +import { createLicensedRouteHandler } from '../licensed_route_handler'; + +const bodySchema = schema.object({ + id: schema.string(), + role_descriptors: schema.recordOf(schema.string(), schema.object({}, { unknowns: 'allow' }), { + defaultValue: {}, + }), + metadata: schema.maybe(schema.object({}, { unknowns: 'allow' })), +}); + +const getBodySchemaWithKibanaPrivileges = ( + getBasePrivilegeNames: () => { global: string[]; space: string[] } +) => + schema.object({ + id: schema.string(), + kibana_role_descriptors: schema.recordOf( + schema.string(), + schema.object({ + elasticsearch: elasticsearchRoleSchema.extends({}, { unknowns: 'allow' }), + kibana: getKibanaRoleSchema(getBasePrivilegeNames), + }) + ), + metadata: schema.maybe(schema.object({}, { unknowns: 'allow' })), + }); + +export function defineUpdateApiKeyRoutes({ + router, + authz, + getAuthenticationService, +}: RouteDefinitionParams) { + const bodySchemaWithKibanaPrivileges = getBodySchemaWithKibanaPrivileges(() => { + const privileges = authz.privileges.get(); + return { + global: Object.keys(privileges.global), + space: Object.keys(privileges.space), + }; + }); + + router.put( + { + path: '/internal/security/api_key', + validate: { + body: schema.oneOf([bodySchema, bodySchemaWithKibanaPrivileges]), + }, + }, + createLicensedRouteHandler(async (context, request, response) => { + try { + const result: UpdateAPIKeyResult | null = await getAuthenticationService().apiKeys.update( + request, + request.body + ); + + if (result === null) { + return response.badRequest({ body: { message: `API Keys are not available` } }); + } + + return response.ok({ body: result }); + } catch (error) { + if (error instanceof UpdateApiKeyValidationError) { + return response.badRequest({ body: { message: error.message } }); + } + return response.customError(wrapIntoCustomErrorResponse(error)); + } + }) + ); +} diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index d4b33e2f9b80c..ace3c64663709 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -26230,7 +26230,6 @@ "xpack.searchProfiler.registryProviderTitle": "Search Profiler", "xpack.searchProfiler.scoreTimeDescription": "Temps passé sur l'attribution de score au document par rapport à la recherche.", "xpack.searchProfiler.trialLicenseTitle": "Trial", - "xpack.security.accountManagement.createApiKey.submitButton": "{isSubmitting, select, true{Création d'une clé d'API…} other{Créer une clé d'API}}", "xpack.security.accountManagement.userProfile.saveChangesButton": "{isSubmitting, select, true{Enregistrement des modifications…} other{Enregistrer les modifications}}", "xpack.security.accountManagement.userProfile.unsavedChangesMessage": "{count, plural, one {# modification non enregistrée} other {# modifications non enregistrées}}", "xpack.security.changePasswordForm.confirmButton": "{isSubmitting, select, true{Modification du mot de passe…} other{Modifier le mot de passe}}", @@ -26341,17 +26340,6 @@ "xpack.security.account.usernameGroupDescription": "Vous ne pouvez pas modifier ces informations.", "xpack.security.account.usernameGroupTitle": "Nom d'utilisateur et e-mail", "xpack.security.accountManagement.apiKeys.retryButton": "Réessayer", - "xpack.security.accountManagement.createApiKey.customExpirationInputLabel": "Durée de vie (jours)", - "xpack.security.accountManagement.createApiKey.customExpirationLabel": "Délai d'expiration", - "xpack.security.accountManagement.createApiKey.customPrivilegesLabel": "Limiter les privilèges", - "xpack.security.accountManagement.createApiKey.errorMessage": "Impossible de créer une clé d'API", - "xpack.security.accountManagement.createApiKey.expirationUnit": "jours", - "xpack.security.accountManagement.createApiKey.includeMetadataLabel": "Inclure les métadonnées", - "xpack.security.accountManagement.createApiKey.metadataHelpText": "Découvrez comment structurer les métadonnées.", - "xpack.security.accountManagement.createApiKey.nameHelpText": "Quelle est l'utilisation de cette clé ?", - "xpack.security.accountManagement.createApiKey.nameLabel": "Nom", - "xpack.security.accountManagement.createApiKey.roleDescriptorsHelpText": "Découvrez comment structurer les descripteurs de rôles.", - "xpack.security.accountManagement.createApiKey.title": "Créer une clé d'API", "xpack.security.accountManagement.userProfile.avatarGroupDescription": "Indiquez vos initiales ou téléchargez une image pour vous représenter.", "xpack.security.accountManagement.userProfile.avatarGroupTitle": "Avatar", "xpack.security.accountManagement.userProfile.avatarTypeGroupDescription": "Type d'avatar", @@ -26453,12 +26441,6 @@ "xpack.security.management.apiKeys.base64Label": "Base64", "xpack.security.management.apiKeys.beatsDescription": "Format utilisé pour la configuration de Beats.", "xpack.security.management.apiKeys.beatsLabel": "Beats", - "xpack.security.management.apiKeys.createApiKey.expirationRequired": "Entrez une durée valide ou désactivez cette option.", - "xpack.security.management.apiKeys.createApiKey.invalidJsonError": "Entrez un JSON valide.", - "xpack.security.management.apiKeys.createApiKey.metadataRequired": "Entrez des métadonnées ou désactivez cette option.", - "xpack.security.management.apiKeys.createApiKey.nameRequired": "Entrez un nom.", - "xpack.security.management.apiKeys.createApiKey.roleDescriptorsRequired": "Entrez des descripteurs de rôles ou désactivez cette option.", - "xpack.security.management.apiKeys.createBreadcrumb": "Créer", "xpack.security.management.apiKeys.createSuccessMessage": "Création de la clé d'API \"{name}\" effectuée", "xpack.security.management.apiKeys.deleteApiKey.confirmModal.cancelButtonLabel": "Annuler", "xpack.security.management.apiKeys.deleteApiKey.confirmModal.deleteMultipleListDescription": "Vous êtes sur le point de supprimer ces clés d'API :", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index e6e3ed09faa31..b9d835e356b4b 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -26207,7 +26207,6 @@ "xpack.searchProfiler.registryProviderTitle": "検索プロファイラー", "xpack.searchProfiler.scoreTimeDescription": "クエリに対してドキュメントを実際にスコアリングするためにかかった時間。", "xpack.searchProfiler.trialLicenseTitle": "トライアル", - "xpack.security.accountManagement.createApiKey.submitButton": "{isSubmitting, select, true{APIキーを作成しています…} other{APIキーを作成}}", "xpack.security.accountManagement.userProfile.saveChangesButton": "{isSubmitting, select, true{変更を保存しています…} other{変更の保存}}", "xpack.security.accountManagement.userProfile.unsavedChangesMessage": "{count, plural, other {# 保存されていない変更}}", "xpack.security.changePasswordForm.confirmButton": "{isSubmitting, select, true{パスワードを変更しています…} other{パスワードの変更}}", @@ -26318,17 +26317,6 @@ "xpack.security.account.usernameGroupDescription": "この情報は変更できません。", "xpack.security.account.usernameGroupTitle": "ユーザー名とメールアドレス", "xpack.security.accountManagement.apiKeys.retryButton": "再試行", - "xpack.security.accountManagement.createApiKey.customExpirationInputLabel": "寿命(日数)", - "xpack.security.accountManagement.createApiKey.customExpirationLabel": "時間の後に有効期限切れ", - "xpack.security.accountManagement.createApiKey.customPrivilegesLabel": "権限を制限", - "xpack.security.accountManagement.createApiKey.errorMessage": "APIキーを作成できませんでした", - "xpack.security.accountManagement.createApiKey.expirationUnit": "日", - "xpack.security.accountManagement.createApiKey.includeMetadataLabel": "メタデータを含む", - "xpack.security.accountManagement.createApiKey.metadataHelpText": "メタデータを構成する方法を参照してください。", - "xpack.security.accountManagement.createApiKey.nameHelpText": "このキーの使用目的", - "xpack.security.accountManagement.createApiKey.nameLabel": "名前", - "xpack.security.accountManagement.createApiKey.roleDescriptorsHelpText": "ロール記述子を構造化する方法をご覧ください。", - "xpack.security.accountManagement.createApiKey.title": "APIキーを作成", "xpack.security.accountManagement.userProfile.avatarGroupDescription": "イニシャルを入力するか、自分を表す画像をアップロードします。", "xpack.security.accountManagement.userProfile.avatarGroupTitle": "アバター", "xpack.security.accountManagement.userProfile.avatarTypeGroupDescription": "アバタータイプ", @@ -26430,12 +26418,6 @@ "xpack.security.management.apiKeys.base64Label": "Base64", "xpack.security.management.apiKeys.beatsDescription": "Beatsを構成するために使用される形式。", "xpack.security.management.apiKeys.beatsLabel": "ビート", - "xpack.security.management.apiKeys.createApiKey.expirationRequired": "有効な期間を入力するか、このオプションを無効にします。", - "xpack.security.management.apiKeys.createApiKey.invalidJsonError": "有効なJSONを入力します。", - "xpack.security.management.apiKeys.createApiKey.metadataRequired": "メタデータを入力するか、このオプションを無効にします。", - "xpack.security.management.apiKeys.createApiKey.nameRequired": "名前を入力します。", - "xpack.security.management.apiKeys.createApiKey.roleDescriptorsRequired": "ロール記述子を入力するか、このオプションを無効にします。", - "xpack.security.management.apiKeys.createBreadcrumb": "作成", "xpack.security.management.apiKeys.createSuccessMessage": "API キー'{name}'を無効にしました", "xpack.security.management.apiKeys.deleteApiKey.confirmModal.cancelButtonLabel": "キャンセル", "xpack.security.management.apiKeys.deleteApiKey.confirmModal.deleteMultipleListDescription": "これらのAPIキーを削除しようとしています。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 03beba86e0184..d07cba48ce3f0 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -26238,7 +26238,6 @@ "xpack.searchProfiler.registryProviderTitle": "Search Profiler", "xpack.searchProfiler.scoreTimeDescription": "基于查询实际评分文档所用的时间。", "xpack.searchProfiler.trialLicenseTitle": "试用", - "xpack.security.accountManagement.createApiKey.submitButton": "{isSubmitting, select, true{正在创建 API 密钥……} other{创建 API 密钥}}", "xpack.security.accountManagement.userProfile.saveChangesButton": "{isSubmitting, select, true{正在保存更改……} other{保存更改}}", "xpack.security.accountManagement.userProfile.unsavedChangesMessage": "{count, plural, other {# 个未保存的更改}}", "xpack.security.changePasswordForm.confirmButton": "{isSubmitting, select, true{正在更改密码……} other{更改密码}}", @@ -26349,17 +26348,6 @@ "xpack.security.account.usernameGroupDescription": "不能更改此信息。", "xpack.security.account.usernameGroupTitle": "用户名和电子邮件", "xpack.security.accountManagement.apiKeys.retryButton": "重试", - "xpack.security.accountManagement.createApiKey.customExpirationInputLabel": "寿命(天)", - "xpack.security.accountManagement.createApiKey.customExpirationLabel": "有效时间", - "xpack.security.accountManagement.createApiKey.customPrivilegesLabel": "限制权限", - "xpack.security.accountManagement.createApiKey.errorMessage": "无法创建 API 密钥", - "xpack.security.accountManagement.createApiKey.expirationUnit": "天", - "xpack.security.accountManagement.createApiKey.includeMetadataLabel": "包括元数据", - "xpack.security.accountManagement.createApiKey.metadataHelpText": "了解如何结构化元数据。", - "xpack.security.accountManagement.createApiKey.nameHelpText": "此密钥的用途?", - "xpack.security.accountManagement.createApiKey.nameLabel": "名称", - "xpack.security.accountManagement.createApiKey.roleDescriptorsHelpText": "了解如何构造角色描述符。", - "xpack.security.accountManagement.createApiKey.title": "创建 API 密钥", "xpack.security.accountManagement.userProfile.avatarGroupDescription": "提供缩写或上传图像来代表您自己。", "xpack.security.accountManagement.userProfile.avatarGroupTitle": "头像", "xpack.security.accountManagement.userProfile.avatarTypeGroupDescription": "头像类型", @@ -26461,12 +26449,6 @@ "xpack.security.management.apiKeys.base64Label": "Base64", "xpack.security.management.apiKeys.beatsDescription": "用于配置 Beats 的格式。", "xpack.security.management.apiKeys.beatsLabel": "Beats", - "xpack.security.management.apiKeys.createApiKey.expirationRequired": "输入有效的时长或禁用此选项。", - "xpack.security.management.apiKeys.createApiKey.invalidJsonError": "输入有效的 JSON。", - "xpack.security.management.apiKeys.createApiKey.metadataRequired": "输入元数据或禁用此选项。", - "xpack.security.management.apiKeys.createApiKey.nameRequired": "输入名称。", - "xpack.security.management.apiKeys.createApiKey.roleDescriptorsRequired": "输入角色描述符或禁用此选项。", - "xpack.security.management.apiKeys.createBreadcrumb": "创建", "xpack.security.management.apiKeys.createSuccessMessage": "已创建 API 密钥“{name}”", "xpack.security.management.apiKeys.deleteApiKey.confirmModal.cancelButtonLabel": "取消", "xpack.security.management.apiKeys.deleteApiKey.confirmModal.deleteMultipleListDescription": "您即将删除以下 API 密钥:", diff --git a/x-pack/test/api_integration/apis/security/api_keys.ts b/x-pack/test/api_integration/apis/security/api_keys.ts index 28d9be63c2db0..84307f3a11d08 100644 --- a/x-pack/test/api_integration/apis/security/api_keys.ts +++ b/x-pack/test/api_integration/apis/security/api_keys.ts @@ -67,6 +67,44 @@ export default function ({ getService }: FtrProviderContext) { }); }); + describe('PUT /internal/security/api_key', () => { + it('should allow an API Key to be updated', async () => { + let id = ''; + + await supertest + .post('/internal/security/api_key') + .set('kbn-xsrf', 'xxx') + .send({ + name: 'test_api_key', + expiration: '12d', + }) + .expect(200) + .then((response: Record) => { + id = response.body.id; + }); + + await supertest + .put('/internal/security/api_key') + .set('kbn-xsrf', 'xxx') + .send({ + id, + metadata: { + foo: 'bar', + }, + role_descriptors: { + role_1: { + cluster: ['monitor'], + }, + }, + }) + .expect(200) + .then((response: Record) => { + const { updated } = response.body; + expect(updated).to.eql(true); + }); + }); + }); + describe('with kibana privileges', () => { describe('POST /internal/security/api_key', () => { it('should allow an API Key to be created', async () => { diff --git a/x-pack/test/functional/apps/api_keys/home_page.ts b/x-pack/test/functional/apps/api_keys/home_page.ts index 588051699a5d7..4813115bf9072 100644 --- a/x-pack/test/functional/apps/api_keys/home_page.ts +++ b/x-pack/test/functional/apps/api_keys/home_page.ts @@ -18,6 +18,25 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const find = getService('find'); const browser = getService('browser'); + const testRoles: Record = { + viewer: { + cluster: ['all'], + indices: [ + { + names: ['*'], + privileges: ['all'], + allow_restricted_indices: false, + }, + { + names: ['*'], + privileges: ['monitor', 'read', 'view_index_metadata', 'read_cross_cluster'], + allow_restricted_indices: true, + }, + ], + run_as: ['*'], + }, + }; + describe('Home page', function () { before(async () => { await clearAllApiKeys(es, log); @@ -61,13 +80,16 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const apiKeyName = 'Happy API Key'; await pageObjects.apiKeys.clickOnPromptCreateApiKey(); expect(await browser.getCurrentUrl()).to.contain('app/management/security/api_keys/create'); + expect(await pageObjects.apiKeys.getFlyoutTitleText()).to.be('Create API Key'); + + expect(await pageObjects.apiKeys.getFlyoutUsername()).to.be('test_user'); await pageObjects.apiKeys.setApiKeyName(apiKeyName); - await pageObjects.apiKeys.submitOnCreateApiKey(); + await pageObjects.apiKeys.clickSubmitButtonOnApiKeyFlyout(); const newApiKeyCreation = await pageObjects.apiKeys.getNewApiKeyCreation(); expect(await browser.getCurrentUrl()).to.not.contain( - 'app/management/security/api_keys/create' + 'app/management/security/api_keys/flyout' ); expect(await browser.getCurrentUrl()).to.contain('app/management/security/api_keys'); expect(await pageObjects.apiKeys.isApiKeyModalExists()).to.be(false); @@ -81,13 +103,13 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await pageObjects.apiKeys.setApiKeyName(apiKeyName); await pageObjects.apiKeys.toggleCustomExpiration(); - await pageObjects.apiKeys.submitOnCreateApiKey(); + await pageObjects.apiKeys.clickSubmitButtonOnApiKeyFlyout(); expect(await pageObjects.apiKeys.getErrorCallOutText()).to.be( 'Enter a valid duration or disable this option.' ); await pageObjects.apiKeys.setApiKeyCustomExpiration('12'); - await pageObjects.apiKeys.submitOnCreateApiKey(); + await pageObjects.apiKeys.clickSubmitButtonOnApiKeyFlyout(); const newApiKeyCreation = await pageObjects.apiKeys.getNewApiKeyCreation(); expect(await browser.getCurrentUrl()).to.not.contain( @@ -99,6 +121,266 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); }); + describe('Update API key', function () { + before(async () => { + await security.testUser.setRoles(['kibana_admin', 'test_api_keys']); + await pageObjects.common.navigateToApp('apiKeys'); + + // Delete any API keys created outside these tests + await pageObjects.apiKeys.bulkDeleteApiKeys(); + }); + + afterEach(async () => { + await pageObjects.apiKeys.deleteAllApiKeyOneByOne(); + }); + + after(async () => { + await clearAllApiKeys(es, log); + }); + + it('should create a new API key, click the name of the new row, fill out and submit form, and display success message', async () => { + // Create a key to updated + const apiKeyName = 'Happy API Key to Update'; + + await es.security.grantApiKey({ + api_key: { + name: apiKeyName, + expiration: '1d', + }, + grant_type: 'password', + run_as: 'test_user', + username: 'elastic', + password: 'changeme', + }); + + await browser.refresh(); + + log.debug('API key created, moving on to update'); + + // Update newly created API Key + await pageObjects.apiKeys.clickExistingApiKeyToOpenFlyout(apiKeyName); + + expect(await browser.getCurrentUrl()).to.contain('app/management/security/api_keys'); + + expect(await pageObjects.apiKeys.getFlyoutTitleText()).to.be('Update API Key'); + + // Verify name input box are disabled + const apiKeyNameInput = await pageObjects.apiKeys.getApiKeyName(); + expect(await apiKeyNameInput.isEnabled()).to.be(false); + + // Status should be displayed + const apiKeyStatus = await pageObjects.apiKeys.getFlyoutApiKeyStatus(); + expect(await apiKeyStatus).to.be('Expires in a day'); + + // Verify metadata is editable + const apiKeyMetadataSwitch = await pageObjects.apiKeys.getMetadataSwitch(); + expect(await apiKeyMetadataSwitch.isEnabled()).to.be(true); + + // Verify restrict privileges is editable + const apiKeyRestrictPrivilegesSwitch = + await pageObjects.apiKeys.getRestrictPrivilegesSwitch(); + expect(await apiKeyRestrictPrivilegesSwitch.isEnabled()).to.be(true); + + // Toggle restrict privileges so the code editor shows up + await apiKeyRestrictPrivilegesSwitch.click(); + + // Toggle metadata switch so the code editor shows up + await apiKeyMetadataSwitch.click(); + + // Check default value of metadata and set value + const restrictPrivilegesCodeEditorValue = + await pageObjects.apiKeys.getCodeEditorValueByIndex(0); + expect(restrictPrivilegesCodeEditorValue).to.be('{}'); + + // Check default value of metadata and set value + const metadataCodeEditorValue = await pageObjects.apiKeys.getCodeEditorValueByIndex(1); + expect(metadataCodeEditorValue).to.be('{}'); + + await pageObjects.apiKeys.setCodeEditorValueByIndex(0, JSON.stringify(testRoles)); + + await pageObjects.apiKeys.setCodeEditorValueByIndex(1, '{"name":"metadataTest"}'); + + // Submit values to update API key + await pageObjects.apiKeys.clickSubmitButtonOnApiKeyFlyout(); + + // Get success message + const updatedApiKeyToastText = await pageObjects.apiKeys.getApiKeyUpdateSuccessToast(); + expect(updatedApiKeyToastText).to.be(`Updated API key '${apiKeyName}'`); + + expect(await browser.getCurrentUrl()).to.contain('app/management/security/api_keys'); + expect(await pageObjects.apiKeys.isApiKeyModalExists()).to.be(false); + }); + }); + + describe('Readonly API key', function () { + before(async () => { + await security.role.create('read_security_role', { + elasticsearch: { + cluster: ['read_security'], + }, + kibana: [ + { + feature: { + infrastructure: ['read'], + }, + spaces: ['*'], + }, + ], + }); + + await security.testUser.setRoles(['kibana_admin', 'test_api_keys']); + await pageObjects.common.navigateToApp('apiKeys'); + + // Delete any API keys created outside these tests + await pageObjects.apiKeys.bulkDeleteApiKeys(); + }); + + afterEach(async () => { + await pageObjects.apiKeys.deleteAllApiKeyOneByOne(); + }); + + after(async () => { + await clearAllApiKeys(es, log); + }); + + it('should see readonly form elements', async () => { + // Create a key to updated + const apiKeyName = 'Happy API Key to View'; + + await es.security.grantApiKey({ + api_key: { + name: apiKeyName, + expiration: '1d', + metadata: { name: 'metadatatest' }, + role_descriptors: { ...testRoles }, + }, + grant_type: 'password', + run_as: 'test_user', + username: 'elastic', + password: 'changeme', + }); + + await browser.refresh(); + + log.debug('API key created, moving on to view'); + + // Set testUsers roles to have the `read_security` cluster privilege + await security.testUser.setRoles(['read_security_role']); + + // View newly created API Key + await pageObjects.apiKeys.clickExistingApiKeyToOpenFlyout(apiKeyName); + expect(await browser.getCurrentUrl()).to.contain('app/management/security/api_keys'); + expect(await pageObjects.apiKeys.getFlyoutTitleText()).to.be('API key details'); + + // Verify name input box are disabled + const apiKeyNameInput = await pageObjects.apiKeys.getApiKeyName(); + expect(await apiKeyNameInput.isEnabled()).to.be(false); + + // Status should be displayed + const apiKeyStatus = await pageObjects.apiKeys.getFlyoutApiKeyStatus(); + expect(await apiKeyStatus).to.be('Expires in a day'); + + const apiKeyMetadataSwitch = await pageObjects.apiKeys.getMetadataSwitch(); + const apiKeyRestrictPrivilegesSwitch = + await pageObjects.apiKeys.getRestrictPrivilegesSwitch(); + + // Verify metadata and restrict privileges switches are now disabled + expect(await apiKeyMetadataSwitch.isEnabled()).to.be(false); + expect(await apiKeyRestrictPrivilegesSwitch.isEnabled()).to.be(false); + + // Close flyout with cancel + await pageObjects.apiKeys.clickCancelButtonOnApiKeyFlyout(); + + // Undo `read_security_role` + await security.testUser.setRoles(['kibana_admin', 'test_api_keys']); + }); + + it('should show the `API key details` flyout if the expiration date is passed', async () => { + const apiKeyName = 'expired-key'; + + await es.security.grantApiKey({ + api_key: { + name: apiKeyName, + expiration: '1ms', + }, + grant_type: 'password', + run_as: 'test_user', + username: 'elastic', + password: 'changeme', + }); + + await browser.refresh(); + + log.debug('API key created, moving on to view'); + + await pageObjects.apiKeys.clickExistingApiKeyToOpenFlyout(apiKeyName); + + expect(await browser.getCurrentUrl()).to.contain('app/management/security/api_keys'); + expect(await pageObjects.apiKeys.getFlyoutTitleText()).to.be('API key details'); + + // Verify name input box are disabled + const apiKeyNameInput = await pageObjects.apiKeys.getApiKeyName(); + expect(await apiKeyNameInput.isEnabled()).to.be(false); + + // Status should be displayed + const apiKeyStatus = await pageObjects.apiKeys.getFlyoutApiKeyStatus(); + expect(await apiKeyStatus).to.be('Expired'); + + const apiKeyMetadataSwitch = await pageObjects.apiKeys.getMetadataSwitch(); + const apiKeyRestrictPrivilegesSwitch = + await pageObjects.apiKeys.getRestrictPrivilegesSwitch(); + + // Verify metadata and restrict privileges switches are now disabled + expect(await apiKeyMetadataSwitch.isEnabled()).to.be(false); + expect(await apiKeyRestrictPrivilegesSwitch.isEnabled()).to.be(false); + + await pageObjects.apiKeys.clickCancelButtonOnApiKeyFlyout(); + }); + + it('should show the `API key details flyout` if the API key does not belong to the user', async () => { + const apiKeyName = 'other-key'; + + await es.security.grantApiKey({ + api_key: { + name: apiKeyName, + }, + grant_type: 'password', + run_as: 'elastic', + username: 'elastic', + password: 'changeme', + }); + + await browser.refresh(); + + log.debug('API key created, moving on to view'); + + await pageObjects.apiKeys.clickExistingApiKeyToOpenFlyout(apiKeyName); + + expect(await browser.getCurrentUrl()).to.contain('app/management/security/api_keys'); + expect(await pageObjects.apiKeys.getFlyoutTitleText()).to.be('API key details'); + + expect(await pageObjects.apiKeys.getFlyoutUsername()).to.be('elastic'); + + // Verify name input box are disabled + const apiKeyNameInput = await pageObjects.apiKeys.getApiKeyName(); + expect(await apiKeyNameInput.isEnabled()).to.be(false); + + // Status should be displayed + const apiKeyStatus = await pageObjects.apiKeys.getFlyoutApiKeyStatus(); + expect(await apiKeyStatus).to.be('Active'); + + const apiKeyMetadataSwitch = await pageObjects.apiKeys.getMetadataSwitch(); + const apiKeyRestrictPrivilegesSwitch = + await pageObjects.apiKeys.getRestrictPrivilegesSwitch(); + + // Verify metadata and restrict privileges switches are now disabled + expect(await apiKeyMetadataSwitch.isEnabled()).to.be(false); + expect(await apiKeyRestrictPrivilegesSwitch.isEnabled()).to.be(false); + + await pageObjects.apiKeys.clickCancelButtonOnApiKeyFlyout(); + }); + }); + describe('deletes API key(s)', function () { before(async () => { await security.testUser.setRoles(['kibana_admin', 'test_api_keys']); @@ -108,7 +390,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { beforeEach(async () => { await pageObjects.apiKeys.clickOnPromptCreateApiKey(); await pageObjects.apiKeys.setApiKeyName('api key 1'); - await pageObjects.apiKeys.submitOnCreateApiKey(); + await pageObjects.apiKeys.clickSubmitButtonOnApiKeyFlyout(); }); it('one by one', async () => { @@ -121,7 +403,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { it('by bulk', async () => { await pageObjects.apiKeys.clickOnTableCreateApiKey(); await pageObjects.apiKeys.setApiKeyName('api key 2'); - await pageObjects.apiKeys.submitOnCreateApiKey(); + await pageObjects.apiKeys.clickSubmitButtonOnApiKeyFlyout(); await pageObjects.apiKeys.bulkDeleteApiKeys(); expect(await pageObjects.apiKeys.getApiKeysFirstPromptTitle()).to.be( diff --git a/x-pack/test/functional/page_objects/api_keys_page.ts b/x-pack/test/functional/page_objects/api_keys_page.ts index 9349eaa4bda0c..6f5bfb5078133 100644 --- a/x-pack/test/functional/page_objects/api_keys_page.ts +++ b/x-pack/test/functional/page_objects/api_keys_page.ts @@ -10,6 +10,7 @@ import { FtrProviderContext } from '../ftr_provider_context'; export function ApiKeysPageProvider({ getService }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const find = getService('find'); + const monacoEditor = getService('monacoEditor'); return { async noAPIKeysHeading() { @@ -40,14 +41,26 @@ export function ApiKeysPageProvider({ getService }: FtrProviderContext) { return await testSubjects.setValue('apiKeyNameInput', apiKeyName); }, + async getApiKeyName() { + return await testSubjects.find('apiKeyNameInput'); + }, + async setApiKeyCustomExpiration(expirationTime: string) { return await testSubjects.setValue('apiKeyCustomExpirationInput', expirationTime); }, - async submitOnCreateApiKey() { + async toggleCustomExpiration() { + return await testSubjects.click('apiKeyCustomExpirationSwitch'); + }, + + async clickSubmitButtonOnApiKeyFlyout() { return await testSubjects.click('formFlyoutSubmitButton'); }, + async clickCancelButtonOnApiKeyFlyout() { + return await testSubjects.click('formFlyoutCancelButton'); + }, + async isApiKeyModalExists() { return await find.existsByCssSelector('[role="dialog"]'); }, @@ -57,10 +70,6 @@ export function ApiKeysPageProvider({ getService }: FtrProviderContext) { return euiCallOutHeader.getVisibleText(); }, - async toggleCustomExpiration() { - return await testSubjects.click('apiKeyCustomExpirationSwitch'); - }, - async getErrorCallOutText() { const alertElem = await find.byCssSelector('[role="dialog"] [role="alert"] .euiText'); return await alertElem.getVisibleText(); @@ -92,5 +101,45 @@ export function ApiKeysPageProvider({ getService }: FtrProviderContext) { await testSubjects.click('confirmModalConfirmButton'); } }, + + async clickExistingApiKeyToOpenFlyout(apiKeyName: string) { + await testSubjects.click(`roleRowName-${apiKeyName}`); + }, + + async getMetadataSwitch() { + return await testSubjects.find('apiKeysMetadataSwitch'); + }, + + async getCodeEditorValueByIndex(index: number) { + return await monacoEditor.getCodeEditorValue(index); + }, + + async setCodeEditorValueByIndex(index: number, data: string) { + await monacoEditor.setCodeEditorValue(data, index); + }, + + async getRestrictPrivilegesSwitch() { + return await testSubjects.find('apiKeysRoleDescriptorsSwitch'); + }, + + async getFlyoutTitleText() { + const header = await find.byClassName('euiFlyoutHeader'); + return header.getVisibleText(); + }, + + async getFlyoutUsername() { + const usernameField = await testSubjects.find('apiKeyFlyoutUsername'); + return usernameField.getVisibleText(); + }, + + async getFlyoutApiKeyStatus() { + const apiKeyStatusField = await testSubjects.find('apiKeyStatus'); + return apiKeyStatusField.getVisibleText(); + }, + + async getApiKeyUpdateSuccessToast() { + const toast = await testSubjects.find('updateApiKeySuccessToast'); + return toast.getVisibleText(); + }, }; }