From 46710bc4d7059dbc5ecb55b8dc83eb2880b6b558 Mon Sep 17 00:00:00 2001 From: Kurt Date: Fri, 18 Nov 2022 15:48:25 -0500 Subject: [PATCH 01/20] Let users update API Keys --- .../api_keys/api_keys_api_client.ts | 16 +++ .../api_keys_grid/api_keys_grid_page.tsx | 84 ++++++++++++- .../api_keys_grid/create_api_key_flyout.tsx | 73 +++++++++-- .../authentication/api_keys/api_keys.mock.ts | 1 + .../authentication/api_keys/api_keys.test.ts | 113 ++++++++++++++++++ .../authentication/api_keys/api_keys.ts | 113 ++++++++++++++++-- .../authentication/authentication_service.ts | 2 + .../security/server/routes/api_keys/index.ts | 2 + .../security/server/routes/api_keys/update.ts | 77 ++++++++++++ 9 files changed, 460 insertions(+), 21 deletions(-) create mode 100644 x-pack/plugins/security/server/routes/api_keys/update.ts 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/api_keys_grid_page.tsx b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_grid_page.tsx index aa94f4f147789..afd025676650e 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,7 +37,11 @@ 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 { ApiKeysEmptyPrompt } from './api_keys_empty_prompt'; import { CreateApiKeyFlyout } from './create_api_key_flyout'; import type { InvalidateApiKeys } from './invalidate_provider'; @@ -61,6 +66,9 @@ interface State { selectedItems: ApiKey[]; error: any; createdApiKey?: CreateApiKeyResponse; + apiKeyUnderEdit?: ApiKey; + updatedApiKey?: UpdateApiKeyResponse; + lastUpdatedApiKeyName?: string; } const DATE_FORMAT = 'MMMM Do YYYY HH:mm:ss'; @@ -81,6 +89,9 @@ export class APIKeysGridPage extends Component { apiKeys: undefined, selectedItems: [], error: undefined, + apiKeyUnderEdit: undefined, + updatedApiKey: undefined, + lastUpdatedApiKeyName: undefined, }; } @@ -99,12 +110,21 @@ export class APIKeysGridPage extends Component { href="/create" > { + onSuccess={(createdApiKeyResponse, updateApiKeyResponse) => { this.props.history.push({ pathname: '/' }); this.reloadApiKeys(); - this.setState({ createdApiKey: apiKey }); + this.setState({ + createdApiKey: createdApiKeyResponse, + updatedApiKey: updateApiKeyResponse, + lastUpdatedApiKeyName: this.state.apiKeyUnderEdit?.name, + apiKeyUnderEdit: undefined, + }); }} - onCancel={() => this.props.history.push({ pathname: '/' })} + onCancel={() => { + this.props.history.push({ pathname: '/' }); + this.setState({ apiKeyUnderEdit: undefined }); + }} + apiKeyUnderEdit={this.state.apiKeyUnderEdit} /> @@ -177,6 +197,8 @@ export class APIKeysGridPage extends Component { const description = this.determineDescription(isAdmin, this.props.readOnly ?? false); + const updatedApiKeyCallOut = this.shouldShowUpdatedApiKeyCallOut(this.state.updatedApiKey); + return ( <> { } /> + {updatedApiKeyCallOut} + {this.state.createdApiKey && !this.state.isLoadingTable && ( <> @@ -475,6 +499,20 @@ export class APIKeysGridPage extends Component { defaultMessage: 'Name', }), sortable: true, + render: (name: string, recordAP: ApiKey) => { + return ( + + { + this.setState({ apiKeyUnderEdit: recordAP }); + })} + > + {name} + + + ); + }, }, ]); @@ -694,4 +732,42 @@ export class APIKeysGridPage extends Component { ); } } + + private shouldShowUpdatedApiKeyCallOut(updateApiKeyResponse?: UpdateApiKeyResponse) { + let result; + + if (updateApiKeyResponse) { + if (updateApiKeyResponse.updated) { + result = ( + <> + + + + ); + } else { + result = ( + <> + + + + ); + } + } + + return result; + } } 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/create_api_key_flyout.tsx index 7a36d9b3e0194..89d4564c488ce 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/create_api_key_flyout.tsx @@ -28,7 +28,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 } from '../../../../common/model'; import { DocLink } from '../../../components/doc_link'; import type { FormFlyoutProps } from '../../../components/form_flyout'; import { FormFlyout } from '../../../components/form_flyout'; @@ -38,7 +38,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; @@ -52,8 +57,12 @@ export interface ApiKeyFormValues { export interface CreateApiKeyFlyoutProps { defaultValues?: ApiKeyFormValues; - onSuccess?: (apiKey: CreateApiKeyResponse) => void; + onSuccess?: ( + createdApiKeyResponse: CreateApiKeyResponse | undefined, + updateApiKeyResponse: UpdateApiKeyResponse | undefined + ) => void; onCancel: FormFlyoutProps['onCancel']; + apiKeyUnderEdit?: ApiKey; } const defaultDefaultValues: ApiKeyFormValues = { @@ -70,18 +79,51 @@ export const CreateApiKeyFlyout: FunctionComponent = ({ onSuccess, onCancel, defaultValues = defaultDefaultValues, + apiKeyUnderEdit, }) => { + let formTitle = 'Create API key'; + let inProgressButtonText = 'Creating API key…'; + + if (apiKeyUnderEdit) { + defaultValues = { + name: apiKeyUnderEdit.name, + expiration: apiKeyUnderEdit.expiration?.toString() ?? '', + customExpiration: !!apiKeyUnderEdit.expiration, + customPrivileges: true, + includeMetadata: !!apiKeyUnderEdit.metadata, + role_descriptors: '{}', + metadata: JSON.stringify(apiKeyUnderEdit.metadata), + }; + + formTitle = 'Update API Key'; + inProgressButtonText = 'Updating API key…'; + } + const { services } = useKibana(); + const { value: currentUser, loading: isLoadingCurrentUser } = useCurrentUser(); + 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 (apiKeyUnderEdit) { + const updateApiKeyResponse = await new APIKeysAPIClient(services.http!).updateApiKey( + mapUpdateApiKeyValues(apiKeyUnderEdit.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 +131,7 @@ export const CreateApiKeyFlyout: FunctionComponent = ({ validate, defaultValues, }); + const isLoading = isLoadingCurrentUser || isLoadingRoles; useEffect(() => { @@ -118,14 +161,14 @@ export const CreateApiKeyFlyout: FunctionComponent = ({ return ( = ({ defaultValue={form.values.name} isInvalid={form.touched.name && !!form.errors.name} inputRef={firstFieldRef} + disabled={!!apiKeyUnderEdit} fullWidth data-test-subj="apiKeyNameInput" /> @@ -259,6 +303,7 @@ export const CreateApiKeyFlyout: FunctionComponent = ({ )} checked={!!form.values.customExpiration} onChange={(e) => form.setValue('customExpiration', e.target.checked)} + disabled={!!apiKeyUnderEdit} data-test-subj="apiKeyCustomExpirationSwitch" /> {form.values.customExpiration && ( @@ -285,6 +330,7 @@ export const CreateApiKeyFlyout: FunctionComponent = ({ min={0} defaultValue={form.values.expiration} isInvalid={form.touched.expiration && !!form.errors.expiration} + disabled={!!apiKeyUnderEdit} fullWidth data-test-subj="apiKeyCustomExpirationInput" /> @@ -412,7 +458,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 +469,14 @@ 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) : undefined, + }; +} 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 e82efeb5168d9..fae8f748aa7db 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(), invalidate: jest.fn(), invalidateAsInternalUser: 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 2aa318acff59d..3fee76a8cefee 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 @@ -204,6 +204,70 @@ 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', 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(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); @@ -591,5 +655,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 0eef6fac74035..325b642781c78 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 @@ -42,6 +42,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 */ @@ -50,6 +60,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; @@ -95,6 +113,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 @@ -210,7 +239,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'); @@ -228,6 +257,47 @@ export class APIKeys { return result; } + /** + * Tries to edit an API key for the current user. + * + * Returns updated API key + * + * @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, + }); + + this.logger.debug('API key was updated successfully'); + } 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. @@ -247,7 +317,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 }, @@ -367,14 +437,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) { @@ -398,9 +477,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; @@ -412,3 +501,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 c22ac5fceecb6..0e503eb4c8776 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' | 'grantAsInternalUser' | 'invalidateAsInternalUser' @@ -352,6 +353,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), invalidateAsInternalUser: apiKeys.invalidateAsInternalUser.bind(apiKeys), 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.ts b/x-pack/plugins/security/server/routes/api_keys/update.ts new file mode 100644 index 0000000000000..0377d00eb00dd --- /dev/null +++ b/x-pack/plugins/security/server/routes/api_keys/update.ts @@ -0,0 +1,77 @@ +/* + * 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 { CreateApiKeyValidationError } from '../../authentication/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 apiKey = await getAuthenticationService().apiKeys.update(request, request.body); + + if (!apiKey) { + console.log('In here'); + return response.badRequest({ body: { message: `API Keys are not available` } }); + } + + return response.ok({ body: apiKey }); + } catch (error) { + if (error instanceof CreateApiKeyValidationError) { + return response.badRequest({ body: { message: error.message } }); + } + return response.customError(wrapIntoCustomErrorResponse(error)); + } + }) + ); +} From e1478f07e1ace193e71b1edeac07dbd35186a944 Mon Sep 17 00:00:00 2001 From: Kurt Date: Wed, 23 Nov 2022 16:26:58 -0500 Subject: [PATCH 02/20] Adding tests and readonly --- .../plugins/security/common/model/api_key.ts | 1 + .../api_keys/api_keys_api_client.mock.ts | 1 + .../api_keys/api_keys_api_client.test.ts | 16 ++ ..._api_key_flyout.tsx => api_key_flyout.tsx} | 98 +++++--- .../api_keys_grid/api_keys_grid_page.tsx | 49 ++-- .../authentication/api_keys/api_keys.test.ts | 37 ++- .../authentication/api_keys/api_keys.ts | 13 +- .../server/routes/api_keys/update.test.ts | 135 +++++++++++ .../security/server/routes/api_keys/update.ts | 15 +- .../functional/apps/api_keys/home_page.ts | 224 +++++++++++++++++- .../functional/page_objects/api_keys_page.ts | 57 ++++- 11 files changed, 569 insertions(+), 77 deletions(-) rename x-pack/plugins/security/public/management/api_keys/api_keys_grid/{create_api_key_flyout.tsx => api_key_flyout.tsx} (83%) create mode 100644 x-pack/plugins/security/server/routes/api_keys/update.test.ts 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/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..80c34937fa0bd 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() calls 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_grid/create_api_key_flyout.tsx b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_key_flyout.tsx similarity index 83% 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 89d4564c488ce..4b7078395d671 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 @@ -20,6 +20,7 @@ import { EuiSwitch, EuiText, } 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'; @@ -55,48 +56,64 @@ export interface ApiKeyFormValues { metadata: string; } -export interface CreateApiKeyFlyoutProps { +export interface ApiKeyFlyoutProps { defaultValues?: ApiKeyFormValues; onSuccess?: ( createdApiKeyResponse: CreateApiKeyResponse | undefined, updateApiKeyResponse: UpdateApiKeyResponse | undefined ) => void; onCancel: FormFlyoutProps['onCancel']; - apiKeyUnderEdit?: ApiKey; + selectedApiKey?: 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, - apiKeyUnderEdit, + selectedApiKey, + readonly = false, }) => { - let formTitle = 'Create API key'; - let inProgressButtonText = 'Creating API key…'; + let formTitle = 'Create API Key'; + let inProgressButtonText = 'Creating API Key…'; + + if (selectedApiKey) { + // Collect data from the selected API key to pre-populate the form + const doesMetadataExist = Object.keys(selectedApiKey.metadata).length > 0; + const doCustomPrivilegesExist = Object.keys(selectedApiKey.role_descriptors ?? 0).length > 0; + const daysUntilExpiration = moment(selectedApiKey.expiration).diff(moment(), 'days', true); + const roundedDaysUntilExpiration = + Math.round((daysUntilExpiration + Number.EPSILON) * 100) / 100; - if (apiKeyUnderEdit) { defaultValues = { - name: apiKeyUnderEdit.name, - expiration: apiKeyUnderEdit.expiration?.toString() ?? '', - customExpiration: !!apiKeyUnderEdit.expiration, - customPrivileges: true, - includeMetadata: !!apiKeyUnderEdit.metadata, - role_descriptors: '{}', - metadata: JSON.stringify(apiKeyUnderEdit.metadata), + name: selectedApiKey.name, + customExpiration: !!selectedApiKey.expiration, + expiration: roundedDaysUntilExpiration.toString(), + includeMetadata: doesMetadataExist, + metadata: doesMetadataExist ? JSON.stringify(selectedApiKey.metadata, null, 2) : '{}', + customPrivileges: doCustomPrivilegesExist, + role_descriptors: doCustomPrivilegesExist + ? JSON.stringify(selectedApiKey.role_descriptors, null, 2) + : '{}', }; - formTitle = 'Update API Key'; - inProgressButtonText = 'Updating API key…'; + if (readonly) { + formTitle = 'View API Key'; + inProgressButtonText = ''; // This won't be seen since Submit will be disabled + } else { + formTitle = 'Update API Key'; + inProgressButtonText = 'Updating API Key…'; + } } const { services } = useKibana(); @@ -111,9 +128,9 @@ export const CreateApiKeyFlyout: FunctionComponent = ({ const [form, eventHandlers] = useForm({ onSubmit: async (values) => { try { - if (apiKeyUnderEdit) { + if (selectedApiKey) { const updateApiKeyResponse = await new APIKeysAPIClient(services.http!).updateApiKey( - mapUpdateApiKeyValues(apiKeyUnderEdit.id, values) + mapUpdateApiKeyValues(selectedApiKey.id, values) ); onSuccess?.(undefined, updateApiKeyResponse); @@ -150,7 +167,8 @@ export const CreateApiKeyFlyout: FunctionComponent = ({ }, {} ); - if (!form.touched.role_descriptors) { + + if (!form.touched.role_descriptors && !selectedApiKey) { form.setValue('role_descriptors', JSON.stringify(userPermissions, null, 2)); } } @@ -160,20 +178,20 @@ export const CreateApiKeyFlyout: FunctionComponent = ({ return ( @@ -244,7 +262,7 @@ export const CreateApiKeyFlyout: FunctionComponent = ({ defaultValue={form.values.name} isInvalid={form.touched.name && !!form.errors.name} inputRef={firstFieldRef} - disabled={!!apiKeyUnderEdit} + disabled={!!selectedApiKey || readonly} fullWidth data-test-subj="apiKeyNameInput" /> @@ -260,12 +278,15 @@ export const CreateApiKeyFlyout: FunctionComponent = ({ } )} checked={!!form.values.customPrivileges} + data-test-subj="apiKeysRoleDescriptorsSwitch" onChange={(e) => form.setValue('customPrivileges', e.target.checked)} + disabled={readonly} /> {form.values.customPrivileges && ( <> = ({ onChange={(value) => form.setValue('role_descriptors', value)} languageId="xjson" height={200} + options={{ readOnly: readonly }} /> @@ -303,7 +325,7 @@ export const CreateApiKeyFlyout: FunctionComponent = ({ )} checked={!!form.values.customExpiration} onChange={(e) => form.setValue('customExpiration', e.target.checked)} - disabled={!!apiKeyUnderEdit} + disabled={!!selectedApiKey || readonly} data-test-subj="apiKeyCustomExpirationSwitch" /> {form.values.customExpiration && ( @@ -311,7 +333,7 @@ export const CreateApiKeyFlyout: FunctionComponent = ({ = ({ name="expiration" min={0} defaultValue={form.values.expiration} - isInvalid={form.touched.expiration && !!form.errors.expiration} - disabled={!!apiKeyUnderEdit} + isInvalid={ + form.touched.expiration && !!form.errors.expiration && !selectedApiKey + } fullWidth data-test-subj="apiKeyCustomExpirationInput" + disabled={!!selectedApiKey || readonly} /> @@ -349,13 +373,16 @@ export const CreateApiKeyFlyout: FunctionComponent = ({ defaultMessage: 'Include metadata', } )} + data-test-subj="apiKeysMetadataSwitch" checked={!!form.values.includeMetadata} + disabled={readonly} onChange={(e) => form.setValue('includeMetadata', e.target.checked)} /> {form.values.includeMetadata && ( <> = ({ onChange={(value) => form.setValue('metadata', value)} languageId="xjson" height={200} + options={{ readOnly: readonly }} /> @@ -390,7 +418,7 @@ export const CreateApiKeyFlyout: FunctionComponent = ({ ); }; -export function validate(values: ApiKeyFormValues) { +export function validate(values: ApiKeyFormValues, isEdit: boolean = false) { const errors: ValidationErrors = {}; if (!values.name) { @@ -399,7 +427,7 @@ export function validate(values: ApiKeyFormValues) { }); } - if (values.customExpiration) { + if (values.customExpiration && !isEdit) { const parsedExpiration = parseFloat(values.expiration); if (isNaN(parsedExpiration) || parsedExpiration <= 0) { errors.expiration = i18n.translate( @@ -474,9 +502,7 @@ export function mapUpdateApiKeyValues(id: string, values: ApiKeyFormValues): Upd return { id, role_descriptors: - values.customPrivileges && values.role_descriptors - ? JSON.parse(values.role_descriptors) - : undefined, - metadata: values.includeMetadata && values.metadata ? JSON.parse(values.metadata) : undefined, + values.customPrivileges && values.role_descriptors ? JSON.parse(values.role_descriptors) : {}, + metadata: values.includeMetadata && values.metadata ? JSON.parse(values.metadata) : {}, }; } 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 afd025676650e..80b7036e17ff7 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 @@ -42,8 +42,8 @@ import type { 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'; @@ -66,7 +66,7 @@ interface State { selectedItems: ApiKey[]; error: any; createdApiKey?: CreateApiKeyResponse; - apiKeyUnderEdit?: ApiKey; + selectedApiKey?: ApiKey; updatedApiKey?: UpdateApiKeyResponse; lastUpdatedApiKeyName?: string; } @@ -89,7 +89,7 @@ export class APIKeysGridPage extends Component { apiKeys: undefined, selectedItems: [], error: undefined, - apiKeyUnderEdit: undefined, + selectedApiKey: undefined, updatedApiKey: undefined, lastUpdatedApiKeyName: undefined, }; @@ -100,31 +100,34 @@ export class APIKeysGridPage extends Component { } public render() { + const breadcrumb = this.determineFlyoutBreadcrumb(); + return ( <> - + - { this.props.history.push({ pathname: '/' }); this.reloadApiKeys(); this.setState({ createdApiKey: createdApiKeyResponse, updatedApiKey: updateApiKeyResponse, - lastUpdatedApiKeyName: this.state.apiKeyUnderEdit?.name, - apiKeyUnderEdit: undefined, + lastUpdatedApiKeyName: this.state.selectedApiKey?.name, + selectedApiKey: undefined, }); }} onCancel={() => { this.props.history.push({ pathname: '/' }); - this.setState({ apiKeyUnderEdit: undefined }); + this.setState({ selectedApiKey: undefined }); }} - apiKeyUnderEdit={this.state.apiKeyUnderEdit} + selectedApiKey={this.state.selectedApiKey} + readonly={this.props.readOnly} /> @@ -178,7 +181,7 @@ export class APIKeysGridPage extends Component { return ( { ? undefined : [ { return ( { - this.setState({ apiKeyUnderEdit: recordAP }); + data-test-subj={`roleRowName-${recordAP.name}`} + {...reactRouterNavigate(this.props.history, `/flyout`, () => { + this.setState({ selectedApiKey: recordAP }); })} > {name} @@ -770,4 +773,18 @@ export class APIKeysGridPage extends Component { return result; } + + determineFlyoutBreadcrumb(): string { + let result = 'Create'; + + if (this.state.selectedApiKey) { + if (this.props.readOnly) { + result = 'View'; + } else { + result = 'Update'; + } + } + + return result; + } } 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 3fee76a8cefee..6b932c53e5bb2 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'; @@ -28,6 +29,7 @@ describe('API Keys', () => { typeof elasticsearchServiceMock.createScopedClusterClient >; let mockLicense: jest.Mocked; + let logger: Logger; beforeEach(() => { mockValidateKibanaPrivileges.mockReset().mockReturnValue({ validationErrors: [] }); @@ -39,9 +41,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: [], @@ -237,11 +241,12 @@ describe('API Keys', () => { // 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', async () => { + it('calls `updateApiKey` with proper parameters and receives `updated: true` in the response', async () => { mockLicense.isEnabled.mockReturnValue(true); mockScopedClusterClient.asCurrentUser.security.updateApiKey.mockResponseOnce({ @@ -258,8 +263,36 @@ describe('API Keys', () => { 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 }, 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 325b642781c78..4d88e22e08f3f 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 @@ -258,9 +258,9 @@ export class APIKeys { } /** - * Tries to edit an API key for the current user. + * Attempts update an API key with the provided 'role_descriptors' and 'metadata' * - * Returns updated API key + * 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 @@ -282,6 +282,9 @@ export class APIKeys { // User needs `manage_api_key` privilege to use this API let result: UpdateAPIKeyResult; + console.log(roleDescriptors); + console.log(metadata); + try { result = await this.clusterClient.asScoped(request).asCurrentUser.security.updateApiKey({ id, @@ -289,7 +292,11 @@ export class APIKeys { metadata, }); - this.logger.debug('API key was updated successfully'); + 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; 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 index 0377d00eb00dd..a7ff22fe58cc2 100644 --- a/x-pack/plugins/security/server/routes/api_keys/update.ts +++ b/x-pack/plugins/security/server/routes/api_keys/update.ts @@ -8,7 +8,8 @@ import { schema } from '@kbn/config-schema'; import type { RouteDefinitionParams } from '..'; -import { CreateApiKeyValidationError } from '../../authentication/api_keys'; +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'; @@ -58,16 +59,18 @@ export function defineUpdateApiKeyRoutes({ }, createLicensedRouteHandler(async (context, request, response) => { try { - const apiKey = await getAuthenticationService().apiKeys.update(request, request.body); + const result: UpdateAPIKeyResult | null = await getAuthenticationService().apiKeys.update( + request, + request.body + ); - if (!apiKey) { - console.log('In here'); + if (result === null) { return response.badRequest({ body: { message: `API Keys are not available` } }); } - return response.ok({ body: apiKey }); + return response.ok({ body: result }); } catch (error) { - if (error instanceof CreateApiKeyValidationError) { + if (error instanceof UpdateApiKeyValidationError) { return response.badRequest({ body: { message: error.message } }); } return response.customError(wrapIntoCustomErrorResponse(error)); 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..0a1f0ed3619e1 100644 --- a/x-pack/test/functional/apps/api_keys/home_page.ts +++ b/x-pack/test/functional/apps/api_keys/home_page.ts @@ -17,6 +17,26 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const testSubjects = getService('testSubjects'); const find = getService('find'); const browser = getService('browser'); + const retryService = getService('retry'); + + const testRoles = { + 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 () => { @@ -60,14 +80,15 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { it('when submitting form, close dialog and displays new api key', async () => { const apiKeyName = 'Happy API Key'; await pageObjects.apiKeys.clickOnPromptCreateApiKey(); - expect(await browser.getCurrentUrl()).to.contain('app/management/security/api_keys/create'); + expect(await browser.getCurrentUrl()).to.contain('app/management/security/api_keys/flyout'); + expect(await pageObjects.apiKeys.getFlyoutTitleText()).to.be('Create API Key'); 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); @@ -77,21 +98,21 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { it('with optional expiration, redirects back and displays base64', async () => { const apiKeyName = 'Happy expiration API key'; await pageObjects.apiKeys.clickOnPromptCreateApiKey(); - expect(await browser.getCurrentUrl()).to.contain('app/management/security/api_keys/create'); + expect(await browser.getCurrentUrl()).to.contain('app/management/security/api_keys/flyout'); 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( - '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); @@ -99,6 +120,191 @@ 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 pageObjects.apiKeys.clickOnPromptCreateApiKey(); + await pageObjects.apiKeys.setApiKeyName(apiKeyName); + await pageObjects.apiKeys.toggleCustomExpiration(); + await pageObjects.apiKeys.setApiKeyCustomExpiration('1'); + await pageObjects.apiKeys.clickSubmitButtonOnApiKeyFlyout(); + + 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/flyout'); + + 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); + + // Verify expiration switch is disabled + const apiKeyExpirationSwitch = await pageObjects.apiKeys.getApiKeyCustomExpirationSwitch(); + expect(await apiKeyExpirationSwitch.isEnabled()).to.be(false); + + const apiKeyExpirationReadonlyInput = + await pageObjects.apiKeys.getApiKeyCustomExpirationInput(); + expect(await apiKeyExpirationReadonlyInput.isEnabled()).to.be(false); + + // 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(); + + // Wait for processing and flyout to close + await retryService.try(async () => { + expect(await browser.getCurrentUrl()).to.not.contain( + '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); + + // Get success message + const updatedApiKeyMessage = await pageObjects.apiKeys.getNewApiKeyCreation(); + expect(updatedApiKeyMessage).to.be(`Updated API key '${apiKeyName}'`); + }); + }); + + 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 pageObjects.apiKeys.clickOnPromptCreateApiKey(); + + await pageObjects.apiKeys.setApiKeyName(apiKeyName); + + await pageObjects.apiKeys.toggleCustomExpiration(); + await pageObjects.apiKeys.setApiKeyCustomExpiration('1'); + + const apiKeyMetadataSwitch = await pageObjects.apiKeys.getMetadataSwitch(); + const apiKeyRestrictPrivilegesSwitch = + await pageObjects.apiKeys.getRestrictPrivilegesSwitch(); + + await apiKeyRestrictPrivilegesSwitch.click(); + await apiKeyMetadataSwitch.click(); + + await pageObjects.apiKeys.setCodeEditorValueByIndex(0, JSON.stringify(testRoles)); + await pageObjects.apiKeys.setCodeEditorValueByIndex(1, '{"name":"metadataTest"}'); + + await pageObjects.apiKeys.clickSubmitButtonOnApiKeyFlyout(); + + 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/flyout'); + expect(await pageObjects.apiKeys.getFlyoutTitleText()).to.be('View API Key'); + + // Verify name input box are disabled + const apiKeyNameInput = await pageObjects.apiKeys.getApiKeyName(); + expect(await apiKeyNameInput.isEnabled()).to.be(false); + + // Verify expiration switch is disabled + const apiKeyExpirationSwitch = await pageObjects.apiKeys.getApiKeyCustomExpirationSwitch(); + expect(await apiKeyExpirationSwitch.isEnabled()).to.be(false); + + // Verify expiration input box is disabled + const apiKeyExpirationInput = await pageObjects.apiKeys.getApiKeyCustomExpirationInput(); + expect(await apiKeyExpirationInput.isEnabled()).to.be(false); + + // Verify metadata and restrict privileges switches are now disabled + expect(await apiKeyMetadataSwitch.isEnabled()).to.be(false); + expect(await apiKeyRestrictPrivilegesSwitch.isEnabled()).to.be(false); + + // Verify the submit button is disabled + const buttonDisabled = await pageObjects.apiKeys.submitButtonOnApiKeyFlyoutDisabled(); + expect(buttonDisabled).to.be('true'); + + // Close flyout with cancel + await pageObjects.apiKeys.clickCancelButtonOnApiKeyFlyout(); + + // Undo `read_security_role` + await security.testUser.setRoles(['kibana_admin', 'test_api_keys']); + }); + }); + describe('deletes API key(s)', function () { before(async () => { await security.testUser.setRoles(['kibana_admin', 'test_api_keys']); @@ -108,7 +314,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 +327,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..339ece69be5b6 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,39 @@ 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 getApiKeyCustomExpirationSwitch() { + return await testSubjects.find('apiKeyCustomExpirationSwitch'); + }, + + async getApiKeyCustomExpirationInput() { + return await testSubjects.find('apiKeyCustomExpirationInput'); + }, + + async toggleCustomExpiration() { + return await testSubjects.click('apiKeyCustomExpirationSwitch'); + }, + + async clickSubmitButtonOnApiKeyFlyout() { return await testSubjects.click('formFlyoutSubmitButton'); }, + async submitButtonOnApiKeyFlyoutDisabled() { + const button = await testSubjects.find('formFlyoutSubmitButton', 20000); + return await button.getAttribute('disabled'); + }, + + async clickCancelButtonOnApiKeyFlyout() { + return await testSubjects.click('formFlyoutCancelButton'); + }, + async isApiKeyModalExists() { return await find.existsByCssSelector('[role="dialog"]'); }, @@ -57,10 +83,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 +114,30 @@ 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(); + }, }; } From d946bfe107cd50160c2b2cebcb10acc52b6fd26b Mon Sep 17 00:00:00 2001 From: Kurt Date: Wed, 23 Nov 2022 16:43:49 -0500 Subject: [PATCH 03/20] Removing debugging logs --- .../security/server/authentication/api_keys/api_keys.ts | 3 --- 1 file changed, 3 deletions(-) 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 4d88e22e08f3f..3da304be02ddc 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 @@ -282,9 +282,6 @@ export class APIKeys { // User needs `manage_api_key` privilege to use this API let result: UpdateAPIKeyResult; - console.log(roleDescriptors); - console.log(metadata); - try { result = await this.clusterClient.asScoped(request).asCurrentUser.security.updateApiKey({ id, From 96fd016ccb9a2be60fb6d6d5ccf19612f61ebea5 Mon Sep 17 00:00:00 2001 From: Kurt Date: Sun, 27 Nov 2022 19:18:16 -0500 Subject: [PATCH 04/20] Fixing message formatting --- .../management/api_keys/api_keys_grid/api_key_flyout.tsx | 7 ++++--- .../api_keys/api_keys_grid/api_keys_grid_page.tsx | 3 ++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_key_flyout.tsx b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_key_flyout.tsx index 4b7078395d671..743816e944389 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_key_flyout.tsx +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_key_flyout.tsx @@ -179,15 +179,16 @@ export const ApiKeyFlyout: FunctionComponent = ({ return ( { From 61a22e701dcb0371a278a9f55526c1bcfbe2fe74 Mon Sep 17 00:00:00 2001 From: Kurt Date: Sun, 27 Nov 2022 19:31:09 -0500 Subject: [PATCH 05/20] Adding braces around sub value --- .../public/management/api_keys/api_keys_grid/api_key_flyout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_key_flyout.tsx b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_key_flyout.tsx index 743816e944389..9354b3d20a4a4 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_key_flyout.tsx +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_key_flyout.tsx @@ -187,7 +187,7 @@ export const ApiKeyFlyout: FunctionComponent = ({ submitButtonText={i18n.translate( 'xpack.security.accountManagement.apiKeyFlyout.submitButton', { - defaultMessage: `{isSubmitting, select, true{inProgressButtonText} other{{formTitle}}}`, + defaultMessage: `{isSubmitting, select, true{{inProgressButtonText}} other{{formTitle}}}`, values: { isSubmitting: form.isSubmitting, inProgressButtonText, formTitle }, } )} From 4658de1a0a8b02f5a9884db6e5de6021d96f75f9 Mon Sep 17 00:00:00 2001 From: Kurt Date: Sun, 27 Nov 2022 20:04:34 -0500 Subject: [PATCH 06/20] Fixing translations --- x-pack/plugins/translations/translations/fr-FR.json | 3 --- x-pack/plugins/translations/translations/ja-JP.json | 3 --- x-pack/plugins/translations/translations/zh-CN.json | 3 --- 3 files changed, 9 deletions(-) diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 67ed617822058..ce523f60467ec 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -24698,7 +24698,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}}", @@ -24819,7 +24818,6 @@ "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", @@ -24925,7 +24923,6 @@ "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 7a2cbd5929707..6a43fe3d7cc50 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -24675,7 +24675,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{パスワードの変更}}", @@ -24796,7 +24795,6 @@ "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": "アバタータイプ", @@ -24902,7 +24900,6 @@ "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 af00cf9497094..058ac6530e8d6 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -24707,7 +24707,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{更改密码}}", @@ -24828,7 +24827,6 @@ "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": "头像类型", @@ -24934,7 +24932,6 @@ "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 密钥:", From 1d493ce4f0c079c7097fc422fb6aea112f798573 Mon Sep 17 00:00:00 2001 From: Kurt Date: Sun, 27 Nov 2022 20:56:09 -0500 Subject: [PATCH 07/20] plugin test fix --- x-pack/plugins/security/server/plugin.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/x-pack/plugins/security/server/plugin.test.ts b/x-pack/plugins/security/server/plugin.test.ts index 10e3b99484f52..fb591e2c98019 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], }, "getCurrentUser": [Function], }, From 1187d80c24b9e64be7f35e3a98e306b51f8280e3 Mon Sep 17 00:00:00 2001 From: Kurt Date: Sun, 27 Nov 2022 22:46:10 -0500 Subject: [PATCH 08/20] Changing other message IDs --- .../api_keys/api_keys_grid/api_key_flyout.tsx | 32 +++++++++---------- .../translations/translations/fr-FR.json | 15 --------- .../translations/translations/ja-JP.json | 15 --------- .../translations/translations/zh-CN.json | 15 --------- 4 files changed, 16 insertions(+), 61 deletions(-) diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_key_flyout.tsx b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_key_flyout.tsx index 9354b3d20a4a4..5b27cce647bf6 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_key_flyout.tsx +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_key_flyout.tsx @@ -199,7 +199,7 @@ export const ApiKeyFlyout: FunctionComponent = ({ {form.submitError && ( <> = ({ = ({ = ({ doc="security-api-create-api-key.html#security-api-create-api-key-request-body" > @@ -319,7 +319,7 @@ export const ApiKeyFlyout: FunctionComponent = ({ = ({ error={form.errors.expiration} isInvalid={form.touched.expiration && !!form.errors.expiration && !selectedApiKey} label={i18n.translate( - 'xpack.security.accountManagement.createApiKey.customExpirationInputLabel', + 'xpack.security.accountManagement.apiKeyFlyout.customExpirationInputLabel', { defaultMessage: 'Lifetime (days)', } @@ -344,7 +344,7 @@ export const ApiKeyFlyout: FunctionComponent = ({ > = ({ = ({ doc="security-api-create-api-key.html#security-api-create-api-key-request-body" > @@ -423,7 +423,7 @@ export function validate(values: ApiKeyFormValues, isEdit: boolean = false) { 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.', }); } @@ -432,7 +432,7 @@ export function validate(values: ApiKeyFormValues, isEdit: boolean = false) { 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.', } @@ -443,7 +443,7 @@ export function validate(values: ApiKeyFormValues, isEdit: boolean = false) { 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.', } @@ -453,7 +453,7 @@ export function validate(values: ApiKeyFormValues, isEdit: boolean = false) { 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.', } @@ -465,7 +465,7 @@ export function validate(values: ApiKeyFormValues, isEdit: boolean = false) { 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.', } @@ -475,7 +475,7 @@ export function validate(values: ApiKeyFormValues, isEdit: boolean = false) { 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.', } diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index ce523f60467ec..9d706f1e48256 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -24808,16 +24808,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.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", @@ -24918,11 +24908,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.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 6a43fe3d7cc50..a80dff91f4a52 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -24785,16 +24785,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.userProfile.avatarGroupDescription": "イニシャルを入力するか、自分を表す画像をアップロードします。", "xpack.security.accountManagement.userProfile.avatarGroupTitle": "アバター", "xpack.security.accountManagement.userProfile.avatarTypeGroupDescription": "アバタータイプ", @@ -24895,11 +24885,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.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 058ac6530e8d6..950db44256a29 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -24817,16 +24817,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.userProfile.avatarGroupDescription": "提供缩写或上传图像来代表您自己。", "xpack.security.accountManagement.userProfile.avatarGroupTitle": "头像", "xpack.security.accountManagement.userProfile.avatarTypeGroupDescription": "头像类型", @@ -24927,11 +24917,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.createSuccessMessage": "已创建 API 密钥“{name}”", "xpack.security.management.apiKeys.deleteApiKey.confirmModal.cancelButtonLabel": "取消", "xpack.security.management.apiKeys.deleteApiKey.confirmModal.deleteMultipleListDescription": "您即将删除以下 API 密钥:", From 227d11d0e463bad77c94ec828cf7021f6a74523e Mon Sep 17 00:00:00 2001 From: Kurt Date: Mon, 28 Nov 2022 08:03:36 -0500 Subject: [PATCH 09/20] Updating API Key docs --- docs/user/security/api-keys/index.asciidoc | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/user/security/api-keys/index.asciidoc b/docs/user/security/api-keys/index.asciidoc index 3efb2cdeef6ca..c9135abdbfa71 100644 --- a/docs/user/security/api-keys/index.asciidoc +++ b/docs/user/security/api-keys/index.asciidoc @@ -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, then click *Stack Management > API Keys*, then click on the name of an existing API key. + +Only the `Restrict privileges` and `metadata` fields can be updated. + [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. From 0e27f9e7baef40ce088729ff106e67e8bef2a887 Mon Sep 17 00:00:00 2001 From: Kurt Date: Mon, 28 Nov 2022 10:08:21 -0500 Subject: [PATCH 10/20] Fixing error message title and adding API test --- .../api_keys/api_keys_grid/api_key_flyout.tsx | 6 ++++- .../api_integration/apis/security/api_keys.ts | 27 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_key_flyout.tsx b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_key_flyout.tsx index 5b27cce647bf6..c9fa9f48bedc2 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_key_flyout.tsx +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_key_flyout.tsx @@ -86,6 +86,7 @@ export const ApiKeyFlyout: FunctionComponent = ({ }) => { let formTitle = 'Create API Key'; let inProgressButtonText = 'Creating API Key…'; + let errorTitle = 'create API key'; if (selectedApiKey) { // Collect data from the selected API key to pre-populate the form @@ -110,9 +111,11 @@ export const ApiKeyFlyout: FunctionComponent = ({ if (readonly) { formTitle = 'View API Key'; 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'; } } @@ -200,7 +203,8 @@ export const ApiKeyFlyout: FunctionComponent = ({ <> 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..b6c4b0ebbcdf3 100644 --- a/x-pack/test/api_integration/apis/security/api_keys.ts +++ b/x-pack/test/api_integration/apis/security/api_keys.ts @@ -12,6 +12,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); + const es = getService('es'); describe('API Keys', () => { describe('GET /internal/security/api_key/_enabled', () => { @@ -67,6 +68,32 @@ export default function ({ getService }: FtrProviderContext) { }); }); + describe('PUT /internal/security/api_key', () => { + it('should allow an API Key to be updated', async () => { + const { id } = await es.security.createApiKey({ name: 'test_key' }); + + 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 () => { From c3d819df5fdf1e6b2d4cf8832a58b178d0fb7a9e Mon Sep 17 00:00:00 2001 From: Kurt Date: Tue, 29 Nov 2022 14:42:37 -0500 Subject: [PATCH 11/20] Fixing for ES validations --- .../api_keys/api_keys_grid/api_key_flyout.tsx | 43 +++-- .../api_keys_grid/api_keys_grid_page.test.tsx | 40 +---- .../functional/apps/api_keys/home_page.ts | 148 +++++++++++++++--- 3 files changed, 163 insertions(+), 68 deletions(-) diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_key_flyout.tsx b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_key_flyout.tsx index c9fa9f48bedc2..ab68ddf9f5762 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_key_flyout.tsx +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_key_flyout.tsx @@ -29,7 +29,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { CodeEditorField, useKibana } from '@kbn/kibana-react-plugin/public'; -import type { ApiKey, 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'; @@ -88,6 +88,10 @@ export const ApiKeyFlyout: FunctionComponent = ({ let inProgressButtonText = 'Creating API Key…'; let errorTitle = 'create API key'; + const { value: currentUser, loading: isLoadingCurrentUser } = useCurrentUser(); + + let canEditApiKey = false; + if (selectedApiKey) { // Collect data from the selected API key to pre-populate the form const doesMetadataExist = Object.keys(selectedApiKey.metadata).length > 0; @@ -108,7 +112,9 @@ export const ApiKeyFlyout: FunctionComponent = ({ : '{}', }; - if (readonly) { + canEditApiKey = isEditable(currentUser, selectedApiKey); + + if (readonly || !canEditApiKey) { formTitle = 'View API Key'; inProgressButtonText = ''; // This won't be seen since Submit will be disabled errorTitle = ''; @@ -121,8 +127,6 @@ export const ApiKeyFlyout: FunctionComponent = ({ const { services } = useKibana(); - const { value: currentUser, loading: isLoadingCurrentUser } = useCurrentUser(); - const [{ value: roles, loading: isLoadingRoles }, getRoles] = useAsyncFn( () => new RolesAPIClient(services.http!).getRoles(), [services.http] @@ -195,7 +199,12 @@ export const ApiKeyFlyout: FunctionComponent = ({ } )} isLoading={form.isSubmitting} - isDisabled={isLoading || (form.isSubmitted && form.isInvalid) || readonly} + isDisabled={ + isLoading || + (form.isSubmitted && form.isInvalid) || + readonly || + (selectedApiKey && !canEditApiKey) + } size="s" ownFocus > @@ -285,7 +294,7 @@ export const ApiKeyFlyout: FunctionComponent = ({ checked={!!form.values.customPrivileges} data-test-subj="apiKeysRoleDescriptorsSwitch" onChange={(e) => form.setValue('customPrivileges', e.target.checked)} - disabled={readonly} + disabled={readonly || (selectedApiKey && !canEditApiKey)} /> {form.values.customPrivileges && ( <> @@ -311,7 +320,7 @@ export const ApiKeyFlyout: FunctionComponent = ({ onChange={(value) => form.setValue('role_descriptors', value)} languageId="xjson" height={200} - options={{ readOnly: readonly }} + options={{ readOnly: readonly || (selectedApiKey && !canEditApiKey) }} /> @@ -330,7 +339,7 @@ export const ApiKeyFlyout: FunctionComponent = ({ )} checked={!!form.values.customExpiration} onChange={(e) => form.setValue('customExpiration', e.target.checked)} - disabled={!!selectedApiKey || readonly} + disabled={readonly || !!selectedApiKey} data-test-subj="apiKeyCustomExpirationSwitch" /> {form.values.customExpiration && ( @@ -361,7 +370,7 @@ export const ApiKeyFlyout: FunctionComponent = ({ } fullWidth data-test-subj="apiKeyCustomExpirationInput" - disabled={!!selectedApiKey || readonly} + disabled={readonly || !!selectedApiKey} /> @@ -380,7 +389,7 @@ export const ApiKeyFlyout: FunctionComponent = ({ )} data-test-subj="apiKeysMetadataSwitch" checked={!!form.values.includeMetadata} - disabled={readonly} + disabled={readonly || (selectedApiKey && !canEditApiKey)} onChange={(e) => form.setValue('includeMetadata', e.target.checked)} /> {form.values.includeMetadata && ( @@ -407,7 +416,7 @@ export const ApiKeyFlyout: FunctionComponent = ({ onChange={(value) => form.setValue('metadata', value)} languageId="xjson" height={200} - options={{ readOnly: readonly }} + options={{ readOnly: readonly || (selectedApiKey && !canEditApiKey) }} /> @@ -511,3 +520,15 @@ export function mapUpdateApiKeyValues(id: string, values: ApiKeyFormValues): Upd metadata: values.includeMetadata && values.metadata ? JSON.parse(values.metadata) : {}, }; } + +function isEditable(currentUser: AuthenticatedUser | undefined, selectedApiKey: ApiKey): boolean { + let result = false; + const isApiKeyOwner = currentUser && currentUser.username === selectedApiKey.username; + const isNotExpired = !selectedApiKey.expiration || moment(selectedApiKey.expiration).isAfter(); + + if (isApiKeyOwner && isNotExpired) { + result = true; + } + + return result; +} 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..bdd79b5ee32f7 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'); + expect(secondKey!.querySelector('a')).not.toBeNull(); }); afterAll(() => { @@ -247,37 +249,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/test/functional/apps/api_keys/home_page.ts b/x-pack/test/functional/apps/api_keys/home_page.ts index 0a1f0ed3619e1..b913b2420b2f3 100644 --- a/x-pack/test/functional/apps/api_keys/home_page.ts +++ b/x-pack/test/functional/apps/api_keys/home_page.ts @@ -19,7 +19,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const browser = getService('browser'); const retryService = getService('retry'); - const testRoles = { + const testRoles: Record = { viewer: { cluster: ['all'], indices: [ @@ -140,11 +140,19 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { 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 pageObjects.apiKeys.clickOnPromptCreateApiKey(); - await pageObjects.apiKeys.setApiKeyName(apiKeyName); - await pageObjects.apiKeys.toggleCustomExpiration(); - await pageObjects.apiKeys.setApiKeyCustomExpiration('1'); - await pageObjects.apiKeys.clickSubmitButtonOnApiKeyFlyout(); + + 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'); @@ -248,24 +256,21 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { it('should see readonly form elements', async () => { // Create a key to updated const apiKeyName = 'Happy API Key to View'; - await pageObjects.apiKeys.clickOnPromptCreateApiKey(); - - await pageObjects.apiKeys.setApiKeyName(apiKeyName); - - await pageObjects.apiKeys.toggleCustomExpiration(); - await pageObjects.apiKeys.setApiKeyCustomExpiration('1'); - const apiKeyMetadataSwitch = await pageObjects.apiKeys.getMetadataSwitch(); - const apiKeyRestrictPrivilegesSwitch = - await pageObjects.apiKeys.getRestrictPrivilegesSwitch(); - - await apiKeyRestrictPrivilegesSwitch.click(); - await apiKeyMetadataSwitch.click(); - - await pageObjects.apiKeys.setCodeEditorValueByIndex(0, JSON.stringify(testRoles)); - await pageObjects.apiKeys.setCodeEditorValueByIndex(1, '{"name":"metadataTest"}'); + 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 pageObjects.apiKeys.clickSubmitButtonOnApiKeyFlyout(); + await browser.refresh(); log.debug('API key created, moving on to view'); @@ -289,6 +294,10 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const apiKeyExpirationInput = await pageObjects.apiKeys.getApiKeyCustomExpirationInput(); expect(await apiKeyExpirationInput.isEnabled()).to.be(false); + 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); @@ -303,6 +312,101 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { // Undo `read_security_role` await security.testUser.setRoles(['kibana_admin', 'test_api_keys']); }); + + it('should show the View API Key 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/flyout'); + expect(await pageObjects.apiKeys.getFlyoutTitleText()).to.be('View API Key'); + + // Verify name input box are disabled + const apiKeyNameInput = await pageObjects.apiKeys.getApiKeyName(); + expect(await apiKeyNameInput.isEnabled()).to.be(false); + + // Verify expiration switch is disabled + const apiKeyExpirationSwitch = await pageObjects.apiKeys.getApiKeyCustomExpirationSwitch(); + expect(await apiKeyExpirationSwitch.isEnabled()).to.be(false); + + // Verify expiration input box is disabled + const apiKeyExpirationInput = await pageObjects.apiKeys.getApiKeyCustomExpirationInput(); + expect(await apiKeyExpirationInput.isEnabled()).to.be(false); + + 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); + + // Verify the submit button is disabled + const buttonDisabled = await pageObjects.apiKeys.submitButtonOnApiKeyFlyoutDisabled(); + expect(buttonDisabled).to.be('true'); + + await pageObjects.apiKeys.clickCancelButtonOnApiKeyFlyout(); + }); + + it('should show the View API Key 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/flyout'); + expect(await pageObjects.apiKeys.getFlyoutTitleText()).to.be('View API Key'); + + // Verify name input box are disabled + const apiKeyNameInput = await pageObjects.apiKeys.getApiKeyName(); + expect(await apiKeyNameInput.isEnabled()).to.be(false); + + // Verify expiration switch is disabled, no expiration will be shown as it was not set on the granted key + const apiKeyExpirationSwitch = await pageObjects.apiKeys.getApiKeyCustomExpirationSwitch(); + expect(await apiKeyExpirationSwitch.isEnabled()).to.be(false); + + 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); + + // Verify the submit button is disabled + const buttonDisabled = await pageObjects.apiKeys.submitButtonOnApiKeyFlyoutDisabled(); + expect(buttonDisabled).to.be('true'); + + await pageObjects.apiKeys.clickCancelButtonOnApiKeyFlyout(); + }); }); describe('deletes API key(s)', function () { From 6626a74768c08d378a4436b61391b0f43da4da1e Mon Sep 17 00:00:00 2001 From: Kurt Date: Tue, 29 Nov 2022 16:26:21 -0500 Subject: [PATCH 12/20] Fixing values sent and username when updating/viewing --- .../management/api_keys/api_keys_grid/api_key_flyout.tsx | 9 ++++++--- x-pack/test/functional/apps/api_keys/home_page.ts | 4 ++++ x-pack/test/functional/page_objects/api_keys_page.ts | 5 +++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_key_flyout.tsx b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_key_flyout.tsx index ab68ddf9f5762..9e466f8c209ad 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_key_flyout.tsx +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_key_flyout.tsx @@ -253,8 +253,9 @@ export const ApiKeyFlyout: FunctionComponent = ({ whiteSpace: 'nowrap', textOverflow: 'ellipsis', }} + data-test-subj="apiKeyFlyoutUsername" > - {currentUser?.username} + {selectedApiKey ? selectedApiKey.username : currentUser?.username} @@ -516,8 +517,10 @@ export function mapUpdateApiKeyValues(id: string, values: ApiKeyFormValues): Upd return { id, role_descriptors: - values.customPrivileges && values.role_descriptors ? JSON.parse(values.role_descriptors) : {}, - metadata: values.includeMetadata && values.metadata ? JSON.parse(values.metadata) : {}, + values.customPrivileges && values.role_descriptors + ? JSON.parse(values.role_descriptors) + : undefined, + metadata: values.includeMetadata && values.metadata ? JSON.parse(values.metadata) : undefined, }; } 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 b913b2420b2f3..e6f6a60e07da6 100644 --- a/x-pack/test/functional/apps/api_keys/home_page.ts +++ b/x-pack/test/functional/apps/api_keys/home_page.ts @@ -83,6 +83,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { expect(await browser.getCurrentUrl()).to.contain('app/management/security/api_keys/flyout'); 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.clickSubmitButtonOnApiKeyFlyout(); const newApiKeyCreation = await pageObjects.apiKeys.getNewApiKeyCreation(); @@ -385,6 +387,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { expect(await browser.getCurrentUrl()).to.contain('app/management/security/api_keys/flyout'); expect(await pageObjects.apiKeys.getFlyoutTitleText()).to.be('View API Key'); + 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); 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 339ece69be5b6..c0a7e6a663cd5 100644 --- a/x-pack/test/functional/page_objects/api_keys_page.ts +++ b/x-pack/test/functional/page_objects/api_keys_page.ts @@ -139,5 +139,10 @@ export function ApiKeysPageProvider({ getService }: FtrProviderContext) { const header = await find.byClassName('euiFlyoutHeader'); return header.getVisibleText(); }, + + async getFlyoutUsername() { + const usernameField = await testSubjects.find('apiKeyFlyoutUsername'); + return usernameField.getVisibleText(); + }, }; } From 6084e4d20f1dba2296da13be59777705afdedc6b Mon Sep 17 00:00:00 2001 From: Kurt Date: Wed, 30 Nov 2022 07:54:50 -0500 Subject: [PATCH 13/20] Fixing API test --- .../api_integration/apis/security/api_keys.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) 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 b6c4b0ebbcdf3..6d035ee63b4af 100644 --- a/x-pack/test/api_integration/apis/security/api_keys.ts +++ b/x-pack/test/api_integration/apis/security/api_keys.ts @@ -12,7 +12,11 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const security = getService('security'); const es = getService('es'); + const config = getService('config'); + const basic = config.get('esTestCluster.license') === 'basic'; describe('API Keys', () => { describe('GET /internal/security/api_key/_enabled', () => { @@ -70,7 +74,19 @@ export default function ({ getService }: FtrProviderContext) { describe('PUT /internal/security/api_key', () => { it('should allow an API Key to be updated', async () => { - const { id } = await es.security.createApiKey({ name: 'test_key' }); + 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') From d317cd49173ee0d9c8f6e42b5a377cbb9ec54f1e Mon Sep 17 00:00:00 2001 From: Kurt Date: Wed, 30 Nov 2022 08:38:09 -0500 Subject: [PATCH 14/20] Removing unused services --- x-pack/test/api_integration/apis/security/api_keys.ts | 5 ----- 1 file changed, 5 deletions(-) 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 6d035ee63b4af..84307f3a11d08 100644 --- a/x-pack/test/api_integration/apis/security/api_keys.ts +++ b/x-pack/test/api_integration/apis/security/api_keys.ts @@ -12,11 +12,6 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); - const supertestWithoutAuth = getService('supertestWithoutAuth'); - const security = getService('security'); - const es = getService('es'); - const config = getService('config'); - const basic = config.get('esTestCluster.license') === 'basic'; describe('API Keys', () => { describe('GET /internal/security/api_key/_enabled', () => { From 7b2746523dfb301c6e4d82871eca0b5398cf98f7 Mon Sep 17 00:00:00 2001 From: Kurt Date: Wed, 7 Dec 2022 22:38:37 -0500 Subject: [PATCH 15/20] PR Review feedback --- .../api_keys/api_keys_api_client.test.ts | 2 +- .../api_keys/api_keys_grid/api_key_flyout.tsx | 153 ++++++++++++------ .../api_keys_grid/api_keys_grid_page.tsx | 4 +- .../functional/apps/api_keys/home_page.ts | 36 ++--- .../functional/page_objects/api_keys_page.ts | 5 + 5 files changed, 123 insertions(+), 77 deletions(-) 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 80c34937fa0bd..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 @@ -101,7 +101,7 @@ describe('APIKeysAPIClient', () => { }); }); - it('updateApiKey() calls correct endpoint', async () => { + it('updateApiKey() queries correct endpoint', async () => { const httpMock = httpServiceMock.createStartContract(); const mockResponse = Symbol('mockResponse'); diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_key_flyout.tsx b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_key_flyout.tsx index 9e466f8c209ad..d504fb1229aa7 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_key_flyout.tsx +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_key_flyout.tsx @@ -14,11 +14,13 @@ import { EuiForm, EuiFormFieldset, EuiFormRow, + EuiHealth, EuiIcon, EuiLoadingContent, EuiSpacer, EuiSwitch, EuiText, + EuiToolTip, } from '@elastic/eui'; import moment from 'moment-timezone'; import type { FunctionComponent } from 'react'; @@ -59,7 +61,7 @@ export interface ApiKeyFormValues { export interface ApiKeyFlyoutProps { defaultValues?: ApiKeyFormValues; onSuccess?: ( - createdApiKeyResponse: CreateApiKeyResponse | undefined, + createApiKeyResponse: CreateApiKeyResponse | undefined, updateApiKeyResponse: UpdateApiKeyResponse | undefined ) => void; onCancel: FormFlyoutProps['onCancel']; @@ -96,14 +98,11 @@ export const ApiKeyFlyout: FunctionComponent = ({ // Collect data from the selected API key to pre-populate the form const doesMetadataExist = Object.keys(selectedApiKey.metadata).length > 0; const doCustomPrivilegesExist = Object.keys(selectedApiKey.role_descriptors ?? 0).length > 0; - const daysUntilExpiration = moment(selectedApiKey.expiration).diff(moment(), 'days', true); - const roundedDaysUntilExpiration = - Math.round((daysUntilExpiration + Number.EPSILON) * 100) / 100; defaultValues = { name: selectedApiKey.name, customExpiration: !!selectedApiKey.expiration, - expiration: roundedDaysUntilExpiration.toString(), + expiration: !!selectedApiKey.expiration ? selectedApiKey.expiration.toString() : '', includeMetadata: doesMetadataExist, metadata: doesMetadataExist ? JSON.stringify(selectedApiKey.metadata, null, 2) : '{}', customPrivileges: doCustomPrivilegesExist, @@ -330,55 +329,67 @@ export const ApiKeyFlyout: FunctionComponent = ({ - - form.setValue('customExpiration', e.target.checked)} - disabled={readonly || !!selectedApiKey} - data-test-subj="apiKeyCustomExpirationSwitch" - /> - {form.values.customExpiration && ( - <> - - + {determineReadonlyExpiration(form.values?.expiration)} + + ) : ( + + form.setValue('customExpiration', e.target.checked)} + disabled={readonly || !!selectedApiKey} + data-test-subj="apiKeyCustomExpirationSwitch" + /> + {form.values.customExpiration && ( + <> + + + - - - - - )} - - + > + + + + + )} + + )} + + + ); + } + + const expirationInt = parseInt(expiration, 10); + + if (Date.now() > expirationInt) { + return ( + + + + ); + } + + return ( + + + + + + ); +} 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 78e60c953c5d0..d8eb02335aa1a 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 @@ -113,11 +113,11 @@ export class APIKeysGridPage extends Component { href="/flyout" > { + onSuccess={(createApiKeyResponse, updateApiKeyResponse) => { this.props.history.push({ pathname: '/' }); this.reloadApiKeys(); this.setState({ - createdApiKey: createdApiKeyResponse, + createdApiKey: createApiKeyResponse, updatedApiKey: updateApiKeyResponse, lastUpdatedApiKeyName: this.state.selectedApiKey?.name, selectedApiKey: undefined, 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 e6f6a60e07da6..42ba13269fd93 100644 --- a/x-pack/test/functional/apps/api_keys/home_page.ts +++ b/x-pack/test/functional/apps/api_keys/home_page.ts @@ -169,13 +169,9 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const apiKeyNameInput = await pageObjects.apiKeys.getApiKeyName(); expect(await apiKeyNameInput.isEnabled()).to.be(false); - // Verify expiration switch is disabled - const apiKeyExpirationSwitch = await pageObjects.apiKeys.getApiKeyCustomExpirationSwitch(); - expect(await apiKeyExpirationSwitch.isEnabled()).to.be(false); - - const apiKeyExpirationReadonlyInput = - await pageObjects.apiKeys.getApiKeyCustomExpirationInput(); - expect(await apiKeyExpirationReadonlyInput.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(); @@ -288,13 +284,9 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const apiKeyNameInput = await pageObjects.apiKeys.getApiKeyName(); expect(await apiKeyNameInput.isEnabled()).to.be(false); - // Verify expiration switch is disabled - const apiKeyExpirationSwitch = await pageObjects.apiKeys.getApiKeyCustomExpirationSwitch(); - expect(await apiKeyExpirationSwitch.isEnabled()).to.be(false); - - // Verify expiration input box is disabled - const apiKeyExpirationInput = await pageObjects.apiKeys.getApiKeyCustomExpirationInput(); - expect(await apiKeyExpirationInput.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 = @@ -342,13 +334,9 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const apiKeyNameInput = await pageObjects.apiKeys.getApiKeyName(); expect(await apiKeyNameInput.isEnabled()).to.be(false); - // Verify expiration switch is disabled - const apiKeyExpirationSwitch = await pageObjects.apiKeys.getApiKeyCustomExpirationSwitch(); - expect(await apiKeyExpirationSwitch.isEnabled()).to.be(false); - - // Verify expiration input box is disabled - const apiKeyExpirationInput = await pageObjects.apiKeys.getApiKeyCustomExpirationInput(); - expect(await apiKeyExpirationInput.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 = @@ -393,9 +381,9 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const apiKeyNameInput = await pageObjects.apiKeys.getApiKeyName(); expect(await apiKeyNameInput.isEnabled()).to.be(false); - // Verify expiration switch is disabled, no expiration will be shown as it was not set on the granted key - const apiKeyExpirationSwitch = await pageObjects.apiKeys.getApiKeyCustomExpirationSwitch(); - expect(await apiKeyExpirationSwitch.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 = 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 c0a7e6a663cd5..dc1921b168ed1 100644 --- a/x-pack/test/functional/page_objects/api_keys_page.ts +++ b/x-pack/test/functional/page_objects/api_keys_page.ts @@ -144,5 +144,10 @@ export function ApiKeysPageProvider({ getService }: FtrProviderContext) { const usernameField = await testSubjects.find('apiKeyFlyoutUsername'); return usernameField.getVisibleText(); }, + + async getFlyoutApiKeyStatus() { + const apiKeyStatusField = await testSubjects.find('apiKeyStatus'); + return apiKeyStatusField.getVisibleText(); + }, }; } From f1b81f2a622a51389dac7802e25b19b4f515bfe1 Mon Sep 17 00:00:00 2001 From: Kurt Date: Wed, 7 Dec 2022 22:39:17 -0500 Subject: [PATCH 16/20] Update docs/user/security/api-keys/index.asciidoc Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> --- docs/user/security/api-keys/index.asciidoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user/security/api-keys/index.asciidoc b/docs/user/security/api-keys/index.asciidoc index c9135abdbfa71..430190c3217ab 100644 --- a/docs/user/security/api-keys/index.asciidoc +++ b/docs/user/security/api-keys/index.asciidoc @@ -55,7 +55,7 @@ API keys are intended for programmatic access to {kib} and {es}. Do not use API [[udpate-api-key]] === Update an API key -To update an API key, open the main menu, then click *Stack Management > API Keys*, then click on the name of an existing 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. Only the `Restrict privileges` and `metadata` fields can be updated. From cb4124762e2dd037126c9035b70b143194419ecd Mon Sep 17 00:00:00 2001 From: Kurt Date: Wed, 7 Dec 2022 22:39:25 -0500 Subject: [PATCH 17/20] Update docs/user/security/api-keys/index.asciidoc Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> --- docs/user/security/api-keys/index.asciidoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user/security/api-keys/index.asciidoc b/docs/user/security/api-keys/index.asciidoc index 430190c3217ab..1e0d32ff6eeb5 100644 --- a/docs/user/security/api-keys/index.asciidoc +++ b/docs/user/security/api-keys/index.asciidoc @@ -57,7 +57,7 @@ API keys are intended for programmatic access to {kib} and {es}. Do not use API To update an API key, open the main menu, click *Stack Management > API Keys*, and then click on the name of the key. -Only the `Restrict privileges` and `metadata` fields can be updated. +You can only update the `Restrict privileges` and `metadata` fields. [float] [[view-api-keys]] From 337988eb7157b2d6618ffc0ce9ee2cf0e6823175 Mon Sep 17 00:00:00 2001 From: Kurt Date: Wed, 7 Dec 2022 22:41:36 -0500 Subject: [PATCH 18/20] Adding docs update about readonly mode --- docs/user/security/api-keys/index.asciidoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user/security/api-keys/index.asciidoc b/docs/user/security/api-keys/index.asciidoc index 1e0d32ff6eeb5..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 <>. From 1a8c8f7df8659bb5ad5dbfe542c0db7dbe12ebf7 Mon Sep 17 00:00:00 2001 From: Kurt Date: Tue, 13 Dec 2022 09:36:16 -0500 Subject: [PATCH 19/20] Changes for PR feedback --- .../public/components/form_flyout.tsx | 28 ++- .../api_keys/api_keys_grid/api_key_flyout.tsx | 209 +++++++++--------- .../api_keys_grid/api_keys_grid_page.tsx | 123 ++++------- .../functional/apps/api_keys/home_page.ts | 50 ++--- .../functional/page_objects/api_keys_page.ts | 18 +- 5 files changed, 189 insertions(+), 239 deletions(-) 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_grid/api_key_flyout.tsx b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_key_flyout.tsx index d504fb1229aa7..f0550d46544bb 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_key_flyout.tsx +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_key_flyout.tsx @@ -65,7 +65,7 @@ export interface ApiKeyFlyoutProps { updateApiKeyResponse: UpdateApiKeyResponse | undefined ) => void; onCancel: FormFlyoutProps['onCancel']; - selectedApiKey?: ApiKey; + apiKey?: ApiKey; readonly?: boolean; } @@ -82,8 +82,7 @@ const defaultDefaultValues: ApiKeyFormValues = { export const ApiKeyFlyout: FunctionComponent = ({ onSuccess, onCancel, - defaultValues = defaultDefaultValues, - selectedApiKey, + apiKey, readonly = false, }) => { let formTitle = 'Create API Key'; @@ -94,27 +93,15 @@ export const ApiKeyFlyout: FunctionComponent = ({ let canEditApiKey = false; - if (selectedApiKey) { - // Collect data from the selected API key to pre-populate the form - const doesMetadataExist = Object.keys(selectedApiKey.metadata).length > 0; - const doCustomPrivilegesExist = Object.keys(selectedApiKey.role_descriptors ?? 0).length > 0; - - defaultValues = { - name: selectedApiKey.name, - customExpiration: !!selectedApiKey.expiration, - expiration: !!selectedApiKey.expiration ? selectedApiKey.expiration.toString() : '', - includeMetadata: doesMetadataExist, - metadata: doesMetadataExist ? JSON.stringify(selectedApiKey.metadata, null, 2) : '{}', - customPrivileges: doCustomPrivilegesExist, - role_descriptors: doCustomPrivilegesExist - ? JSON.stringify(selectedApiKey.role_descriptors, null, 2) - : '{}', - }; - - canEditApiKey = isEditable(currentUser, selectedApiKey); + let defaultValues = defaultDefaultValues; + + if (apiKey) { + defaultValues = retrieveValuesFromApiKeyToDefaultFlyout(apiKey); + + canEditApiKey = isEditable(currentUser, apiKey); if (readonly || !canEditApiKey) { - formTitle = 'View API Key'; + formTitle = 'API key details'; inProgressButtonText = ''; // This won't be seen since Submit will be disabled errorTitle = ''; } else { @@ -134,9 +121,9 @@ export const ApiKeyFlyout: FunctionComponent = ({ const [form, eventHandlers] = useForm({ onSubmit: async (values) => { try { - if (selectedApiKey) { + if (apiKey) { const updateApiKeyResponse = await new APIKeysAPIClient(services.http!).updateApiKey( - mapUpdateApiKeyValues(selectedApiKey.id, values) + mapUpdateApiKeyValues(apiKey.id, values) ); onSuccess?.(undefined, updateApiKeyResponse); @@ -174,7 +161,7 @@ export const ApiKeyFlyout: FunctionComponent = ({ {} ); - if (!form.touched.role_descriptors && !selectedApiKey) { + if (!form.touched.role_descriptors && !apiKey) { form.setValue('role_descriptors', JSON.stringify(userPermissions, null, 2)); } } @@ -199,11 +186,9 @@ export const ApiKeyFlyout: FunctionComponent = ({ )} isLoading={form.isSubmitting} isDisabled={ - isLoading || - (form.isSubmitted && form.isInvalid) || - readonly || - (selectedApiKey && !canEditApiKey) + isLoading || (form.isSubmitted && form.isInvalid) || readonly || (apiKey && !canEditApiKey) } + isSubmitButtonHidden={readonly || (!!apiKey && !canEditApiKey)} size="s" ownFocus > @@ -254,7 +239,7 @@ export const ApiKeyFlyout: FunctionComponent = ({ }} data-test-subj="apiKeyFlyoutUsername" > - {selectedApiKey ? selectedApiKey.username : currentUser?.username} + {apiKey ? apiKey.username : currentUser?.username} @@ -265,9 +250,6 @@ export const ApiKeyFlyout: FunctionComponent = ({ label={i18n.translate('xpack.security.accountManagement.apiKeyFlyout.nameLabel', { defaultMessage: 'Name', })} - helpText={i18n.translate('xpack.security.accountManagement.apiKeyFlyout.nameHelpText', { - defaultMessage: 'What is this key used for?', - })} error={form.errors.name} isInvalid={form.touched.name && !!form.errors.name} > @@ -276,12 +258,25 @@ export const ApiKeyFlyout: FunctionComponent = ({ defaultValue={form.values.name} isInvalid={form.touched.name && !!form.errors.name} inputRef={firstFieldRef} - disabled={!!selectedApiKey || readonly} + disabled={!!apiKey || readonly} fullWidth data-test-subj="apiKeyNameInput" /> + {!!apiKey && ( + <> + + + {determineReadonlyExpiration(form.values?.expiration)} + + + )} + = ({ defaultMessage: 'Restrict privileges', } )} - checked={!!form.values.customPrivileges} + checked={form.values.customPrivileges} data-test-subj="apiKeysRoleDescriptorsSwitch" onChange={(e) => form.setValue('customPrivileges', e.target.checked)} - disabled={readonly || (selectedApiKey && !canEditApiKey)} + disabled={readonly || (apiKey && !canEditApiKey)} /> {form.values.customPrivileges && ( <> @@ -320,7 +315,7 @@ export const ApiKeyFlyout: FunctionComponent = ({ onChange={(value) => form.setValue('role_descriptors', value)} languageId="xjson" height={200} - options={{ readOnly: readonly || (selectedApiKey && !canEditApiKey) }} + options={{ readOnly: readonly || (apiKey && !canEditApiKey) }} /> @@ -328,67 +323,57 @@ export const ApiKeyFlyout: FunctionComponent = ({ )} - - {!!selectedApiKey ? ( - - {determineReadonlyExpiration(form.values?.expiration)} - - ) : ( - - form.setValue('customExpiration', e.target.checked)} - disabled={readonly || !!selectedApiKey} - data-test-subj="apiKeyCustomExpirationSwitch" - /> - {form.values.customExpiration && ( - <> - - - + + + - form.setValue('customExpiration', e.target.checked)} + disabled={readonly || !!apiKey} + data-test-subj="apiKeyCustomExpirationSwitch" + /> + {form.values.customExpiration && ( + <> + + + - - - - )} - + > + + + + + )} + + )} @@ -400,8 +385,8 @@ export const ApiKeyFlyout: FunctionComponent = ({ } )} data-test-subj="apiKeysMetadataSwitch" - checked={!!form.values.includeMetadata} - disabled={readonly || (selectedApiKey && !canEditApiKey)} + checked={form.values.includeMetadata} + disabled={readonly || (apiKey && !canEditApiKey)} onChange={(e) => form.setValue('includeMetadata', e.target.checked)} /> {form.values.includeMetadata && ( @@ -428,7 +413,7 @@ export const ApiKeyFlyout: FunctionComponent = ({ onChange={(value) => form.setValue('metadata', value)} languageId="xjson" height={200} - options={{ readOnly: readonly || (selectedApiKey && !canEditApiKey) }} + options={{ readOnly: readonly || (apiKey && !canEditApiKey) }} /> @@ -444,7 +429,7 @@ export const ApiKeyFlyout: FunctionComponent = ({ ); }; -export function validate(values: ApiKeyFormValues, isEdit: boolean = false) { +export function validate(values: ApiKeyFormValues) { const errors: ValidationErrors = {}; if (!values.name) { @@ -453,7 +438,7 @@ export function validate(values: ApiKeyFormValues, isEdit: boolean = false) { }); } - if (values.customExpiration && !isEdit) { + if (values.customExpiration) { const parsedExpiration = parseFloat(values.expiration); if (isNaN(parsedExpiration) || parsedExpiration <= 0) { errors.expiration = i18n.translate( @@ -531,14 +516,14 @@ export function mapUpdateApiKeyValues(id: string, values: ApiKeyFormValues): Upd values.customPrivileges && values.role_descriptors ? JSON.parse(values.role_descriptors) : undefined, - metadata: values.includeMetadata && values.metadata ? JSON.parse(values.metadata) : undefined, + metadata: values.includeMetadata && values.metadata ? JSON.parse(values.metadata) : {}, }; } -function isEditable(currentUser: AuthenticatedUser | undefined, selectedApiKey: ApiKey): boolean { +function isEditable(currentUser: AuthenticatedUser | undefined, apiKey: ApiKey): boolean { let result = false; - const isApiKeyOwner = currentUser && currentUser.username === selectedApiKey.username; - const isNotExpired = !selectedApiKey.expiration || moment(selectedApiKey.expiration).isAfter(); + const isApiKeyOwner = currentUser && currentUser.username === apiKey.username; + const isNotExpired = !apiKey.expiration || moment(apiKey.expiration).isAfter(); if (isApiKeyOwner && isNotExpired) { result = true; @@ -588,3 +573,21 @@ function determineReadonlyExpiration(expiration?: string) { ); } + +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.tsx b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_grid_page.tsx index d8eb02335aa1a..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 @@ -67,8 +67,7 @@ interface State { error: any; createdApiKey?: CreateApiKeyResponse; selectedApiKey?: ApiKey; - updatedApiKey?: UpdateApiKeyResponse; - lastUpdatedApiKeyName?: string; + isUpdateFlyoutVisible: boolean; } const DATE_FORMAT = 'MMMM Do YYYY HH:mm:ss'; @@ -90,8 +89,7 @@ export class APIKeysGridPage extends Component { selectedItems: [], error: undefined, selectedApiKey: undefined, - updatedApiKey: undefined, - lastUpdatedApiKeyName: undefined, + isUpdateFlyoutVisible: false, }; } @@ -100,38 +98,54 @@ export class APIKeysGridPage extends Component { } public render() { - const breadcrumb = this.determineFlyoutBreadcrumb(); - return ( + // Flyout to create new ApiKey <> - + { + onSuccess={(createApiKeyResponse) => { this.props.history.push({ pathname: '/' }); + this.reloadApiKeys(); + this.setState({ createdApiKey: createApiKeyResponse, - updatedApiKey: updateApiKeyResponse, - lastUpdatedApiKeyName: this.state.selectedApiKey?.name, - selectedApiKey: undefined, }); }} onCancel={() => { this.props.history.push({ pathname: '/' }); this.setState({ selectedApiKey: undefined }); }} - selectedApiKey={this.state.selectedApiKey} - readonly={this.props.readOnly} /> + + { + // 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()} ); @@ -182,10 +196,11 @@ export class APIKeysGridPage extends Component { return ( { const description = this.determineDescription(isAdmin, this.props.readOnly ?? false); - const updatedApiKeyCallOut = this.shouldShowUpdatedApiKeyCallOut(this.state.updatedApiKey); - return ( <> { ? undefined : [ { } /> - {updatedApiKeyCallOut} - {this.state.createdApiKey && !this.state.isLoadingTable && ( <> @@ -508,9 +519,9 @@ export class APIKeysGridPage extends Component { { - this.setState({ selectedApiKey: recordAP }); - })} + onClick={() => { + this.setState({ selectedApiKey: recordAP, isUpdateFlyoutVisible: true }); + }} > {name} @@ -699,21 +710,21 @@ export class APIKeysGridPage extends Component { return ( ); } else if (readOnly) { return ( ); } else { return ( ); } @@ -737,55 +748,15 @@ export class APIKeysGridPage extends Component { } } - private shouldShowUpdatedApiKeyCallOut(updateApiKeyResponse?: UpdateApiKeyResponse) { - let result; - + private displayUpdatedApiKeyToast(updateApiKeyResponse?: UpdateApiKeyResponse) { if (updateApiKeyResponse) { - if (updateApiKeyResponse.updated) { - result = ( - <> - - - - ); - } else { - result = ( - <> - - - - ); - } - } - - return result; - } - - determineFlyoutBreadcrumb(): string { - let result = 'Create'; - - if (this.state.selectedApiKey) { - if (this.props.readOnly) { - result = 'View'; - } else { - result = 'Update'; - } + 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', + }); } - - return result; } } 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 42ba13269fd93..4813115bf9072 100644 --- a/x-pack/test/functional/apps/api_keys/home_page.ts +++ b/x-pack/test/functional/apps/api_keys/home_page.ts @@ -17,7 +17,6 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const testSubjects = getService('testSubjects'); const find = getService('find'); const browser = getService('browser'); - const retryService = getService('retry'); const testRoles: Record = { viewer: { @@ -80,7 +79,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { it('when submitting form, close dialog and displays new api key', async () => { const apiKeyName = 'Happy API Key'; await pageObjects.apiKeys.clickOnPromptCreateApiKey(); - expect(await browser.getCurrentUrl()).to.contain('app/management/security/api_keys/flyout'); + 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'); @@ -100,7 +99,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { it('with optional expiration, redirects back and displays base64', async () => { const apiKeyName = 'Happy expiration API key'; await pageObjects.apiKeys.clickOnPromptCreateApiKey(); - expect(await browser.getCurrentUrl()).to.contain('app/management/security/api_keys/flyout'); + expect(await browser.getCurrentUrl()).to.contain('app/management/security/api_keys/create'); await pageObjects.apiKeys.setApiKeyName(apiKeyName); await pageObjects.apiKeys.toggleCustomExpiration(); @@ -114,7 +113,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const newApiKeyCreation = await pageObjects.apiKeys.getNewApiKeyCreation(); expect(await browser.getCurrentUrl()).to.not.contain( - 'app/management/security/api_keys/flyout' + 'app/management/security/api_keys/create' ); expect(await browser.getCurrentUrl()).to.contain('app/management/security/api_keys'); expect(await pageObjects.apiKeys.isApiKeyModalExists()).to.be(false); @@ -161,7 +160,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { // Update newly created API Key await pageObjects.apiKeys.clickExistingApiKeyToOpenFlyout(apiKeyName); - expect(await browser.getCurrentUrl()).to.contain('app/management/security/api_keys/flyout'); + expect(await browser.getCurrentUrl()).to.contain('app/management/security/api_keys'); expect(await pageObjects.apiKeys.getFlyoutTitleText()).to.be('Update API Key'); @@ -204,19 +203,12 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { // Submit values to update API key await pageObjects.apiKeys.clickSubmitButtonOnApiKeyFlyout(); - // Wait for processing and flyout to close - await retryService.try(async () => { - expect(await browser.getCurrentUrl()).to.not.contain( - 'app/management/security/api_keys/flyout' - ); - }); + // 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); - - // Get success message - const updatedApiKeyMessage = await pageObjects.apiKeys.getNewApiKeyCreation(); - expect(updatedApiKeyMessage).to.be(`Updated API key '${apiKeyName}'`); }); }); @@ -277,8 +269,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { // View newly created API Key await pageObjects.apiKeys.clickExistingApiKeyToOpenFlyout(apiKeyName); - expect(await browser.getCurrentUrl()).to.contain('app/management/security/api_keys/flyout'); - expect(await pageObjects.apiKeys.getFlyoutTitleText()).to.be('View API Key'); + 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(); @@ -296,10 +288,6 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { expect(await apiKeyMetadataSwitch.isEnabled()).to.be(false); expect(await apiKeyRestrictPrivilegesSwitch.isEnabled()).to.be(false); - // Verify the submit button is disabled - const buttonDisabled = await pageObjects.apiKeys.submitButtonOnApiKeyFlyoutDisabled(); - expect(buttonDisabled).to.be('true'); - // Close flyout with cancel await pageObjects.apiKeys.clickCancelButtonOnApiKeyFlyout(); @@ -307,7 +295,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await security.testUser.setRoles(['kibana_admin', 'test_api_keys']); }); - it('should show the View API Key flyout if the expiration date is passed', async () => { + it('should show the `API key details` flyout if the expiration date is passed', async () => { const apiKeyName = 'expired-key'; await es.security.grantApiKey({ @@ -327,8 +315,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await pageObjects.apiKeys.clickExistingApiKeyToOpenFlyout(apiKeyName); - expect(await browser.getCurrentUrl()).to.contain('app/management/security/api_keys/flyout'); - expect(await pageObjects.apiKeys.getFlyoutTitleText()).to.be('View API Key'); + 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(); @@ -346,14 +334,10 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { expect(await apiKeyMetadataSwitch.isEnabled()).to.be(false); expect(await apiKeyRestrictPrivilegesSwitch.isEnabled()).to.be(false); - // Verify the submit button is disabled - const buttonDisabled = await pageObjects.apiKeys.submitButtonOnApiKeyFlyoutDisabled(); - expect(buttonDisabled).to.be('true'); - await pageObjects.apiKeys.clickCancelButtonOnApiKeyFlyout(); }); - it('should show the View API Key flyout if the API key does not belong to the user', async () => { + 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({ @@ -372,8 +356,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await pageObjects.apiKeys.clickExistingApiKeyToOpenFlyout(apiKeyName); - expect(await browser.getCurrentUrl()).to.contain('app/management/security/api_keys/flyout'); - expect(await pageObjects.apiKeys.getFlyoutTitleText()).to.be('View API Key'); + 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'); @@ -393,10 +377,6 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { expect(await apiKeyMetadataSwitch.isEnabled()).to.be(false); expect(await apiKeyRestrictPrivilegesSwitch.isEnabled()).to.be(false); - // Verify the submit button is disabled - const buttonDisabled = await pageObjects.apiKeys.submitButtonOnApiKeyFlyoutDisabled(); - expect(buttonDisabled).to.be('true'); - await pageObjects.apiKeys.clickCancelButtonOnApiKeyFlyout(); }); }); 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 dc1921b168ed1..6f5bfb5078133 100644 --- a/x-pack/test/functional/page_objects/api_keys_page.ts +++ b/x-pack/test/functional/page_objects/api_keys_page.ts @@ -49,14 +49,6 @@ export function ApiKeysPageProvider({ getService }: FtrProviderContext) { return await testSubjects.setValue('apiKeyCustomExpirationInput', expirationTime); }, - async getApiKeyCustomExpirationSwitch() { - return await testSubjects.find('apiKeyCustomExpirationSwitch'); - }, - - async getApiKeyCustomExpirationInput() { - return await testSubjects.find('apiKeyCustomExpirationInput'); - }, - async toggleCustomExpiration() { return await testSubjects.click('apiKeyCustomExpirationSwitch'); }, @@ -65,11 +57,6 @@ export function ApiKeysPageProvider({ getService }: FtrProviderContext) { return await testSubjects.click('formFlyoutSubmitButton'); }, - async submitButtonOnApiKeyFlyoutDisabled() { - const button = await testSubjects.find('formFlyoutSubmitButton', 20000); - return await button.getAttribute('disabled'); - }, - async clickCancelButtonOnApiKeyFlyout() { return await testSubjects.click('formFlyoutCancelButton'); }, @@ -149,5 +136,10 @@ export function ApiKeysPageProvider({ getService }: FtrProviderContext) { const apiKeyStatusField = await testSubjects.find('apiKeyStatus'); return apiKeyStatusField.getVisibleText(); }, + + async getApiKeyUpdateSuccessToast() { + const toast = await testSubjects.find('updateApiKeySuccessToast'); + return toast.getVisibleText(); + }, }; } From 1a10fc8554117d40f58030df28ab77d74a268909 Mon Sep 17 00:00:00 2001 From: Kurt Date: Tue, 13 Dec 2022 10:48:25 -0500 Subject: [PATCH 20/20] Fixing test --- .../api_keys/api_keys_grid/api_keys_grid_page.test.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 bdd79b5ee32f7..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 @@ -108,11 +108,15 @@ describe('APIKeysGridPage', () => { ); expect(await queryByTestId('apiKeysCreateTableButton')).toBeNull(); + expect(await findByText(/Loading API keys/)).not.toBeInTheDocument(); + await findByText(/first-api-key/); const secondKey = getByText(/second-api-key/).closest('td'); - expect(secondKey!.querySelector('a')).not.toBeNull(); + const secondKeyEuiLink = secondKey!.querySelector('button'); + expect(secondKeyEuiLink).not.toBeNull(); + expect(secondKeyEuiLink!.getAttribute('data-test-subj')).toBe('roleRowName-second-api-key'); }); afterAll(() => {