From 904d2c565401840dc9f46cc359840f7f04f3c8cd Mon Sep 17 00:00:00 2001 From: Thom Heymann Date: Fri, 4 Feb 2022 10:21:57 +0000 Subject: [PATCH 01/13] Add user profile service and APIs --- x-pack/plugins/security/server/plugin.ts | 21 ++++ .../server/profile/profile_service.ts | 103 ++++++++++++++++++ .../plugins/security/server/routes/index.ts | 4 + .../security/server/routes/profile/get.ts | 32 ++++++ .../security/server/routes/profile/index.ts | 15 +++ .../security/server/routes/profile/update.ts | 33 ++++++ 6 files changed, 208 insertions(+) create mode 100644 x-pack/plugins/security/server/profile/profile_service.ts create mode 100644 x-pack/plugins/security/server/routes/profile/get.ts create mode 100644 x-pack/plugins/security/server/routes/profile/index.ts create mode 100644 x-pack/plugins/security/server/routes/profile/update.ts diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index b02f852551948..45f668841296e 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -46,6 +46,8 @@ import { ElasticsearchService } from './elasticsearch'; import type { SecurityFeatureUsageServiceStart } from './feature_usage'; import { SecurityFeatureUsageService } from './feature_usage'; import { securityFeatures } from './features'; +import type { ProfileServiceStart } from './profile/profile_service'; +import { ProfileService } from './profile/profile_service'; import { defineRoutes } from './routes'; import { setupSavedObjects } from './saved_objects'; import type { Session } from './session_management'; @@ -101,6 +103,11 @@ export interface SecurityPluginStart { * Authorization services to manage and access the permissions a particular user has. */ authz: AuthorizationServiceSetup; + + /** + * Access and manage user profiles and application data. + */ + profile: ProfileServiceStart; } export interface PluginSetupDependencies { @@ -185,6 +192,15 @@ export class SecurityPlugin return this.anonymousAccessStart; }; + private readonly profileService: ProfileService; + private profileStart?: ProfileServiceStart; + private readonly getProfileService = () => { + if (!this.profileStart) { + throw new Error(`profileStart is not registered!`); + } + return this.profileStart; + }; + constructor(private readonly initializerContext: PluginInitializerContext) { this.logger = this.initializerContext.logger.get(); @@ -202,6 +218,7 @@ export class SecurityPlugin this.initializerContext.logger.get('anonymous-access'), this.getConfig ); + this.profileService = new ProfileService(this.initializerContext.logger.get('profile')); } public setup( @@ -308,6 +325,7 @@ export class SecurityPlugin getFeatureUsageService: this.getFeatureUsageService, getAuthenticationService: this.getAuthentication, getAnonymousAccessService: this.getAnonymousAccess, + getProfileService: this.getProfileService, }); return Object.freeze({ @@ -374,6 +392,8 @@ export class SecurityPlugin spaces: spaces?.spacesService, }); + this.profileStart = this.profileService.start(clusterClient.asInternalUser); + return Object.freeze({ authc: { apiKeys: this.authenticationStart.apiKeys, @@ -388,6 +408,7 @@ export class SecurityPlugin this.authorizationSetup!.checkSavedObjectsPrivilegesWithRequest, mode: this.authorizationSetup!.mode, }, + profile: this.profileStart, }); } diff --git a/x-pack/plugins/security/server/profile/profile_service.ts b/x-pack/plugins/security/server/profile/profile_service.ts new file mode 100644 index 0000000000000..6667c2878376e --- /dev/null +++ b/x-pack/plugins/security/server/profile/profile_service.ts @@ -0,0 +1,103 @@ +/* + * 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 type { ElasticsearchClient, Logger } from 'src/core/server'; + +const KIBANA_DATA_ROOT = 'kibana'; + +export interface Profile { + uid: string; + user: User; + data: T; +} + +export interface User { + username: string; + roles: string[]; + realm_name: string; + full_name: string; + display_name?: string; + avatar?: { + initials?: string; + color?: string; + image_url?: string; + }; + active: boolean; +} + +export type UserData = Record; + +export interface ProfileServiceStart { + /** + * Creates or updates an existing user profile. + */ + // activate(params: { username: string; password: string }): Promise; + + /** + * Retrieves a single user profile by identifier. + * @param uid User ID + * @param dataPath By default `get()` returns user information, but does not return any user data. The optional "dataPath" parameter can be used to return personal data for this user. + */ + get(uid: string, dataPath?: string): Promise>; + + /** + * Updates user preferences by identifier. + * @param uid User ID + * @param data Application data to be written (merged with existing data). + */ + update(uid: string, data: T): Promise; +} + +type GetProfileResponse = Record< + string, + { + uid: string; + user: User; + data: { + [KIBANA_DATA_ROOT]: T; + }; + access: {}; + enabled: boolean; + last_synchronized: number; + } +>; + +export class ProfileService { + constructor(private readonly logger: Logger) {} + + start(elasticsearchClient: ElasticsearchClient): ProfileServiceStart { + const { logger } = this; + logger.info('ProfileService: start'); + + async function get(uid: string, dataPath?: string) { + const { body } = await elasticsearchClient.transport.request>({ + method: 'GET', + path: `_security/profile/${uid}${dataPath ? `?data=${KIBANA_DATA_ROOT}.${dataPath}` : ''}`, + }); + logger.info('ProfileService: get', body); + + const { user, data } = body[uid]; + + return { uid, user, data: data[KIBANA_DATA_ROOT] }; + } + + async function update(uid: string, data: T) { + logger.info('ProfileService: update', data); + await elasticsearchClient.transport.request({ + method: 'POST', + path: `_security/profile/_data/${uid}`, + body: { + data: { + [KIBANA_DATA_ROOT]: data, + }, + }, + }); + } + + return { get, update }; + } +} diff --git a/x-pack/plugins/security/server/routes/index.ts b/x-pack/plugins/security/server/routes/index.ts index 6785fe57c6b32..9a96bd8f4acb8 100644 --- a/x-pack/plugins/security/server/routes/index.ts +++ b/x-pack/plugins/security/server/routes/index.ts @@ -17,6 +17,7 @@ import type { InternalAuthenticationServiceStart } from '../authentication'; import type { AuthorizationServiceSetupInternal } from '../authorization'; import type { ConfigType } from '../config'; import type { SecurityFeatureUsageServiceStart } from '../feature_usage'; +import type { ProfileServiceStart } from '../profile/profile_service'; import type { Session } from '../session_management'; import type { SecurityRouter } from '../types'; import { defineAnonymousAccessRoutes } from './anonymous_access'; @@ -25,6 +26,7 @@ import { defineAuthenticationRoutes } from './authentication'; import { defineAuthorizationRoutes } from './authorization'; import { defineDeprecationsRoutes } from './deprecations'; import { defineIndicesRoutes } from './indices'; +import { defineProfileRoutes } from './profile'; import { defineRoleMappingRoutes } from './role_mapping'; import { defineSecurityCheckupGetStateRoutes } from './security_checkup'; import { defineSessionManagementRoutes } from './session_management'; @@ -47,6 +49,7 @@ export interface RouteDefinitionParams { getFeatures: () => Promise; getFeatureUsageService: () => SecurityFeatureUsageServiceStart; getAuthenticationService: () => InternalAuthenticationServiceStart; + getProfileService: () => ProfileServiceStart; getAnonymousAccessService: () => AnonymousAccessServiceStart; } @@ -57,6 +60,7 @@ export function defineRoutes(params: RouteDefinitionParams) { defineApiKeysRoutes(params); defineIndicesRoutes(params); defineUsersRoutes(params); + defineProfileRoutes(params); defineRoleMappingRoutes(params); defineViewRoutes(params); defineDeprecationsRoutes(params); diff --git a/x-pack/plugins/security/server/routes/profile/get.ts b/x-pack/plugins/security/server/routes/profile/get.ts new file mode 100644 index 0000000000000..6f9cedf874170 --- /dev/null +++ b/x-pack/plugins/security/server/routes/profile/get.ts @@ -0,0 +1,32 @@ +/* + * 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 { wrapIntoCustomErrorResponse } from '../../errors'; +import { createLicensedRouteHandler } from '../licensed_route_handler'; + +export function defineGetProfileRoute({ router, getProfileService }: RouteDefinitionParams) { + router.get( + { + path: '/internal/security/profile/{uid}', + validate: { + params: schema.object({ uid: schema.string() }), + }, + }, + createLicensedRouteHandler(async (context, request, response) => { + const profileService = getProfileService(); + try { + const profile = await profileService.get(request.params.uid, '*'); + return response.ok({ body: profile }); + } catch (error) { + return response.customError(wrapIntoCustomErrorResponse(error)); + } + }) + ); +} diff --git a/x-pack/plugins/security/server/routes/profile/index.ts b/x-pack/plugins/security/server/routes/profile/index.ts new file mode 100644 index 0000000000000..ae42ec8c3aee1 --- /dev/null +++ b/x-pack/plugins/security/server/routes/profile/index.ts @@ -0,0 +1,15 @@ +/* + * 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 type { RouteDefinitionParams } from '../index'; +import { defineGetProfileRoute } from './get'; +import { defineUpdateProfileDataRoute } from './update'; + +export function defineProfileRoutes(params: RouteDefinitionParams) { + defineUpdateProfileDataRoute(params); + defineGetProfileRoute(params); +} diff --git a/x-pack/plugins/security/server/routes/profile/update.ts b/x-pack/plugins/security/server/routes/profile/update.ts new file mode 100644 index 0000000000000..4141cde3d149d --- /dev/null +++ b/x-pack/plugins/security/server/routes/profile/update.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; + +import type { RouteDefinitionParams } from '../'; +import { wrapIntoCustomErrorResponse } from '../../errors'; +import { createLicensedRouteHandler } from '../licensed_route_handler'; + +export function defineUpdateProfileDataRoute({ router, getProfileService }: RouteDefinitionParams) { + router.post( + { + path: '/internal/security/profile/_data/{uid}', + validate: { + params: schema.object({ uid: schema.string() }), + body: schema.recordOf(schema.string(), schema.any()), + }, + }, + createLicensedRouteHandler(async (context, request, response) => { + const profileService = getProfileService(); + try { + await profileService.update(request.params.uid, request.body); + return response.ok(); + } catch (error) { + return response.customError(wrapIntoCustomErrorResponse(error)); + } + }) + ); +} From 5a3b9f7968d36f426623b5e47beaf31b7e41eef4 Mon Sep 17 00:00:00 2001 From: Thom Heymann Date: Tue, 8 Feb 2022 13:32:42 +0000 Subject: [PATCH 02/13] Added suggestions from code review --- x-pack/plugins/security/server/plugin.ts | 18 ++--- .../plugins/security/server/routes/index.ts | 4 +- .../security/server/routes/profile/get.ts | 9 ++- .../security/server/routes/profile/update.ts | 12 +++- .../security/server/user_profile/index.ts | 13 ++++ .../user_profile_service.ts} | 72 +++++++++---------- 6 files changed, 72 insertions(+), 56 deletions(-) create mode 100644 x-pack/plugins/security/server/user_profile/index.ts rename x-pack/plugins/security/server/{profile/profile_service.ts => user_profile/user_profile_service.ts} (56%) diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index 45f668841296e..b710d200259be 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -46,14 +46,14 @@ import { ElasticsearchService } from './elasticsearch'; import type { SecurityFeatureUsageServiceStart } from './feature_usage'; import { SecurityFeatureUsageService } from './feature_usage'; import { securityFeatures } from './features'; -import type { ProfileServiceStart } from './profile/profile_service'; -import { ProfileService } from './profile/profile_service'; import { defineRoutes } from './routes'; import { setupSavedObjects } from './saved_objects'; import type { Session } from './session_management'; import { SessionManagementService } from './session_management'; import { setupSpacesClient } from './spaces'; import { registerSecurityUsageCollector } from './usage_collector'; +import { UserProfileService } from './user_profile'; +import type { UserProfileServiceStart } from './user_profile'; export type SpacesService = Pick< SpacesPluginSetup['spacesService'], @@ -103,11 +103,6 @@ export interface SecurityPluginStart { * Authorization services to manage and access the permissions a particular user has. */ authz: AuthorizationServiceSetup; - - /** - * Access and manage user profiles and application data. - */ - profile: ProfileServiceStart; } export interface PluginSetupDependencies { @@ -192,8 +187,8 @@ export class SecurityPlugin return this.anonymousAccessStart; }; - private readonly profileService: ProfileService; - private profileStart?: ProfileServiceStart; + private readonly profileService: UserProfileService; + private profileStart?: UserProfileServiceStart; private readonly getProfileService = () => { if (!this.profileStart) { throw new Error(`profileStart is not registered!`); @@ -218,7 +213,7 @@ export class SecurityPlugin this.initializerContext.logger.get('anonymous-access'), this.getConfig ); - this.profileService = new ProfileService(this.initializerContext.logger.get('profile')); + this.profileService = new UserProfileService(this.initializerContext.logger.get('profile')); } public setup( @@ -325,7 +320,7 @@ export class SecurityPlugin getFeatureUsageService: this.getFeatureUsageService, getAuthenticationService: this.getAuthentication, getAnonymousAccessService: this.getAnonymousAccess, - getProfileService: this.getProfileService, + getUserProfileService: this.getProfileService, }); return Object.freeze({ @@ -408,7 +403,6 @@ export class SecurityPlugin this.authorizationSetup!.checkSavedObjectsPrivilegesWithRequest, mode: this.authorizationSetup!.mode, }, - profile: this.profileStart, }); } diff --git a/x-pack/plugins/security/server/routes/index.ts b/x-pack/plugins/security/server/routes/index.ts index 9a96bd8f4acb8..12703a535eb84 100644 --- a/x-pack/plugins/security/server/routes/index.ts +++ b/x-pack/plugins/security/server/routes/index.ts @@ -17,9 +17,9 @@ import type { InternalAuthenticationServiceStart } from '../authentication'; import type { AuthorizationServiceSetupInternal } from '../authorization'; import type { ConfigType } from '../config'; import type { SecurityFeatureUsageServiceStart } from '../feature_usage'; -import type { ProfileServiceStart } from '../profile/profile_service'; import type { Session } from '../session_management'; import type { SecurityRouter } from '../types'; +import type { UserProfileServiceStart } from '../user_profile/user_profile_service'; import { defineAnonymousAccessRoutes } from './anonymous_access'; import { defineApiKeysRoutes } from './api_keys'; import { defineAuthenticationRoutes } from './authentication'; @@ -49,7 +49,7 @@ export interface RouteDefinitionParams { getFeatures: () => Promise; getFeatureUsageService: () => SecurityFeatureUsageServiceStart; getAuthenticationService: () => InternalAuthenticationServiceStart; - getProfileService: () => ProfileServiceStart; + getUserProfileService: () => UserProfileServiceStart; getAnonymousAccessService: () => AnonymousAccessServiceStart; } diff --git a/x-pack/plugins/security/server/routes/profile/get.ts b/x-pack/plugins/security/server/routes/profile/get.ts index 6f9cedf874170..f3e01ba6fb424 100644 --- a/x-pack/plugins/security/server/routes/profile/get.ts +++ b/x-pack/plugins/security/server/routes/profile/get.ts @@ -11,18 +11,21 @@ import type { RouteDefinitionParams } from '../'; import { wrapIntoCustomErrorResponse } from '../../errors'; import { createLicensedRouteHandler } from '../licensed_route_handler'; -export function defineGetProfileRoute({ router, getProfileService }: RouteDefinitionParams) { +export function defineGetProfileRoute({ router, getUserProfileService }: RouteDefinitionParams) { router.get( { path: '/internal/security/profile/{uid}', + options: { + tags: ['access:accessUserProfile', 'access:updateUserProfle'], + }, validate: { params: schema.object({ uid: schema.string() }), }, }, createLicensedRouteHandler(async (context, request, response) => { - const profileService = getProfileService(); + const userProfileService = getUserProfileService(); try { - const profile = await profileService.get(request.params.uid, '*'); + const profile = await userProfileService.get(request.params.uid, '*'); return response.ok({ body: profile }); } catch (error) { return response.customError(wrapIntoCustomErrorResponse(error)); diff --git a/x-pack/plugins/security/server/routes/profile/update.ts b/x-pack/plugins/security/server/routes/profile/update.ts index 4141cde3d149d..719e2b8036113 100644 --- a/x-pack/plugins/security/server/routes/profile/update.ts +++ b/x-pack/plugins/security/server/routes/profile/update.ts @@ -11,19 +11,25 @@ import type { RouteDefinitionParams } from '../'; import { wrapIntoCustomErrorResponse } from '../../errors'; import { createLicensedRouteHandler } from '../licensed_route_handler'; -export function defineUpdateProfileDataRoute({ router, getProfileService }: RouteDefinitionParams) { +export function defineUpdateProfileDataRoute({ + router, + getUserProfileService, +}: RouteDefinitionParams) { router.post( { path: '/internal/security/profile/_data/{uid}', + options: { + tags: ['access:accessUserProfile', 'access:updateUserProfle'], + }, validate: { params: schema.object({ uid: schema.string() }), body: schema.recordOf(schema.string(), schema.any()), }, }, createLicensedRouteHandler(async (context, request, response) => { - const profileService = getProfileService(); + const userProfileService = getUserProfileService(); try { - await profileService.update(request.params.uid, request.body); + await userProfileService.update(request.params.uid, request.body); return response.ok(); } catch (error) { return response.customError(wrapIntoCustomErrorResponse(error)); diff --git a/x-pack/plugins/security/server/user_profile/index.ts b/x-pack/plugins/security/server/user_profile/index.ts new file mode 100644 index 0000000000000..2e46a3ac571c3 --- /dev/null +++ b/x-pack/plugins/security/server/user_profile/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { + UserData, + UserProfile, + UserProfileService, + UserProfileServiceStart, +} from './user_profile_service'; diff --git a/x-pack/plugins/security/server/profile/profile_service.ts b/x-pack/plugins/security/server/user_profile/user_profile_service.ts similarity index 56% rename from x-pack/plugins/security/server/profile/profile_service.ts rename to x-pack/plugins/security/server/user_profile/user_profile_service.ts index 6667c2878376e..ba12844744d3a 100644 --- a/x-pack/plugins/security/server/profile/profile_service.ts +++ b/x-pack/plugins/security/server/user_profile/user_profile_service.ts @@ -7,19 +7,17 @@ import type { ElasticsearchClient, Logger } from 'src/core/server'; +import type { User } from '../../common'; + const KIBANA_DATA_ROOT = 'kibana'; -export interface Profile { +export interface UserProfile { uid: string; - user: User; + user: UserInfo; data: T; } -export interface User { - username: string; - roles: string[]; - realm_name: string; - full_name: string; +export interface UserInfo extends User { display_name?: string; avatar?: { initials?: string; @@ -31,18 +29,13 @@ export interface User { export type UserData = Record; -export interface ProfileServiceStart { - /** - * Creates or updates an existing user profile. - */ - // activate(params: { username: string; password: string }): Promise; - +export interface UserProfileServiceStart { /** * Retrieves a single user profile by identifier. * @param uid User ID * @param dataPath By default `get()` returns user information, but does not return any user data. The optional "dataPath" parameter can be used to return personal data for this user. */ - get(uid: string, dataPath?: string): Promise>; + get(uid: string, dataPath?: string): Promise>; /** * Updates user preferences by identifier. @@ -56,7 +49,7 @@ type GetProfileResponse = Record< string, { uid: string; - user: User; + user: UserInfo; data: { [KIBANA_DATA_ROOT]: T; }; @@ -66,36 +59,43 @@ type GetProfileResponse = Record< } >; -export class ProfileService { +export class UserProfileService { constructor(private readonly logger: Logger) {} - start(elasticsearchClient: ElasticsearchClient): ProfileServiceStart { + start(elasticsearchClient: ElasticsearchClient): UserProfileServiceStart { const { logger } = this; - logger.info('ProfileService: start'); async function get(uid: string, dataPath?: string) { - const { body } = await elasticsearchClient.transport.request>({ - method: 'GET', - path: `_security/profile/${uid}${dataPath ? `?data=${KIBANA_DATA_ROOT}.${dataPath}` : ''}`, - }); - logger.info('ProfileService: get', body); - - const { user, data } = body[uid]; - - return { uid, user, data: data[KIBANA_DATA_ROOT] }; + try { + const { body } = await elasticsearchClient.transport.request>({ + method: 'GET', + path: `_security/profile/${uid}${ + dataPath ? `?data=${KIBANA_DATA_ROOT}.${dataPath}` : '' + }`, + }); + const { user, data } = body[uid]; + return { uid, user, data: data[KIBANA_DATA_ROOT] }; + } catch (error) { + logger.error(`Failed to retrieve user profile [uid=${uid}]: ${error.message}`); + throw error; + } } async function update(uid: string, data: T) { - logger.info('ProfileService: update', data); - await elasticsearchClient.transport.request({ - method: 'POST', - path: `_security/profile/_data/${uid}`, - body: { - data: { - [KIBANA_DATA_ROOT]: data, + try { + await elasticsearchClient.transport.request({ + method: 'POST', + path: `_security/profile/_data/${uid}`, + body: { + data: { + [KIBANA_DATA_ROOT]: data, + }, }, - }, - }); + }); + } catch (error) { + logger.error(`Failed to update user profile [uid=${uid}]: ${error.message}`); + throw error; + } } return { get, update }; From 17055fe7a462c016b4ab3a10956cc36323419906 Mon Sep 17 00:00:00 2001 From: Thom Heymann Date: Thu, 10 Feb 2022 11:53:06 +0000 Subject: [PATCH 03/13] Added unit tests --- .../user_profile/user_profile_service.test.ts | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 x-pack/plugins/security/server/user_profile/user_profile_service.test.ts diff --git a/x-pack/plugins/security/server/user_profile/user_profile_service.test.ts b/x-pack/plugins/security/server/user_profile/user_profile_service.test.ts new file mode 100644 index 0000000000000..8263392daf2b6 --- /dev/null +++ b/x-pack/plugins/security/server/user_profile/user_profile_service.test.ts @@ -0,0 +1,120 @@ +/* + * 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 { elasticsearchServiceMock, loggingSystemMock } from 'src/core/server/mocks'; + +import { UserProfileService } from './user_profile_service'; + +const logger = loggingSystemMock.createLogger(); +const userProfileService = new UserProfileService(logger); +const elasticsearchClient = elasticsearchServiceMock.createElasticsearchClient(); + +describe('UserProfileService', () => { + beforeEach(() => { + elasticsearchClient.transport.request.mockReturnValue( + elasticsearchServiceMock.createSuccessTransportRequestPromise({ + UID: { + uid: 'UID', + user: {}, + data: { + kibana: { + avatar: 'fun.gif', + }, + other_app: { + secret: 'data', + }, + }, + }, + }) + ); + }); + + afterEach(() => { + elasticsearchClient.transport.request.mockClear(); + logger.error.mockClear(); + }); + + it('should expose correct start contract', () => { + const startContract = userProfileService.start(elasticsearchClient); + expect(startContract).toMatchInlineSnapshot(` + Object { + "get": [Function], + "update": [Function], + } + `); + }); + + it('should get user profile', async () => { + const startContract = userProfileService.start(elasticsearchClient); + await expect(startContract.get('UID')).resolves.toMatchInlineSnapshot(` + Object { + "data": Object { + "avatar": "fun.gif", + }, + "uid": "UID", + "user": Object {}, + } + `); + expect(elasticsearchClient.transport.request).toHaveBeenCalledWith({ + method: 'GET', + path: '_security/profile/UID', + }); + }); + + it('should handle errors when get user profile fails', async () => { + elasticsearchClient.transport.request.mockRejectedValue(new Error('Fail')); + const startContract = userProfileService.start(elasticsearchClient); + await expect(startContract.get('UID')).rejects.toMatchInlineSnapshot(`[Error: Fail]`); + expect(logger.error).toHaveBeenCalled(); + }); + + it('should get user profile and application data scoped to Kibana', async () => { + const startContract = userProfileService.start(elasticsearchClient); + await expect(startContract.get('UID', '*')).resolves.toMatchInlineSnapshot(` + Object { + "data": Object { + "avatar": "fun.gif", + }, + "uid": "UID", + "user": Object {}, + } + `); + expect(elasticsearchClient.transport.request).toHaveBeenCalledWith({ + method: 'GET', + path: '_security/profile/UID?data=kibana.*', + }); + }); + + it('should update application data scoped to Kibana', async () => { + const startContract = userProfileService.start(elasticsearchClient); + await startContract.update('UID', { + avatar: 'boring.png', + }); + expect(elasticsearchClient.transport.request).toHaveBeenCalledWith({ + body: { + data: { + kibana: { + avatar: 'boring.png', + }, + }, + }, + method: 'POST', + path: '_security/profile/_data/UID', + }); + }); + + it('should handle errors when update user profile fails', async () => { + elasticsearchClient.transport.request.mockRejectedValue(new Error('Fail')); + const startContract = userProfileService.start(elasticsearchClient); + await expect( + startContract.update('UID', { + avatar: 'boring.png', + }) + ).rejects.toMatchInlineSnapshot(`[Error: Fail]`); + expect(logger.error).toHaveBeenCalled(); + }); +}); From 1e90fa47f6af9f65a695b987cd75860fb35f8bf2 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Tue, 15 Feb 2022 09:24:13 +0100 Subject: [PATCH 04/13] Activate user profile on login. (#124552) --- .../authentication_result.test.ts | 62 +++- .../authentication/authentication_result.ts | 43 ++- .../authentication_service.test.ts | 3 + .../authentication/authentication_service.ts | 4 + .../authentication/authenticator.test.ts | 114 +++++++- .../server/authentication/authenticator.ts | 24 +- .../authentication/providers/basic.test.ts | 1 + .../server/authentication/providers/basic.ts | 6 +- .../authentication/providers/kerberos.test.ts | 2 + .../authentication/providers/kerberos.ts | 1 + .../authentication/providers/oidc.test.ts | 1 + .../server/authentication/providers/oidc.ts | 3 +- .../authentication/providers/pki.test.ts | 4 + .../server/authentication/providers/pki.ts | 22 +- .../authentication/providers/saml.test.ts | 13 + .../server/authentication/providers/saml.ts | 3 +- .../authentication/providers/token.test.ts | 6 +- .../server/authentication/providers/token.ts | 1 + x-pack/plugins/security/server/plugin.ts | 23 +- .../plugins/security/server/routes/index.ts | 6 +- .../routes/{profile => user_profile}/get.ts | 11 +- .../routes/{profile => user_profile}/index.ts | 10 +- .../{profile => user_profile}/update.ts | 8 +- .../server/session_management/session.mock.ts | 1 + .../server/session_management/session.test.ts | 172 ++++++++++- .../server/session_management/session.ts | 41 ++- .../security/server/user_profile/index.ts | 9 +- .../server/user_profile/user_profile.mock.ts | 18 ++ .../server/user_profile/user_profile.ts | 45 +++ .../server/user_profile/user_profile_grant.ts | 28 ++ .../user_profile/user_profile_service.mock.ts | 17 ++ .../user_profile/user_profile_service.test.ts | 266 +++++++++++++----- .../user_profile/user_profile_service.ts | 68 +++-- 33 files changed, 857 insertions(+), 179 deletions(-) rename x-pack/plugins/security/server/routes/{profile => user_profile}/get.ts (80%) rename x-pack/plugins/security/server/routes/{profile => user_profile}/index.ts (53%) rename x-pack/plugins/security/server/routes/{profile => user_profile}/update.ts (84%) create mode 100644 x-pack/plugins/security/server/user_profile/user_profile.mock.ts create mode 100644 x-pack/plugins/security/server/user_profile/user_profile.ts create mode 100644 x-pack/plugins/security/server/user_profile/user_profile_grant.ts create mode 100644 x-pack/plugins/security/server/user_profile/user_profile_service.mock.ts diff --git a/x-pack/plugins/security/server/authentication/authentication_result.test.ts b/x-pack/plugins/security/server/authentication/authentication_result.test.ts index e890f99e0da6e..73465a9fdaf1e 100644 --- a/x-pack/plugins/security/server/authentication/authentication_result.test.ts +++ b/x-pack/plugins/security/server/authentication/authentication_result.test.ts @@ -8,6 +8,7 @@ import Boom from '@hapi/boom'; import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock'; +import type { UserProfileGrant } from '../user_profile'; import { AuthenticationResult } from './authentication_result'; describe('AuthenticationResult', () => { @@ -21,6 +22,7 @@ describe('AuthenticationResult', () => { expect(authenticationResult.redirected()).toBe(false); expect(authenticationResult.user).toBeUndefined(); + expect(authenticationResult.userProfileGrant).toBeUndefined(); expect(authenticationResult.state).toBeUndefined(); expect(authenticationResult.error).toBeUndefined(); expect(authenticationResult.authHeaders).toBeUndefined(); @@ -47,6 +49,7 @@ describe('AuthenticationResult', () => { expect(authenticationResult.error).toBe(failureReason); expect(authenticationResult.user).toBeUndefined(); + expect(authenticationResult.userProfileGrant).toBeUndefined(); expect(authenticationResult.state).toBeUndefined(); expect(authenticationResult.authHeaders).toBeUndefined(); expect(authenticationResult.authResponseHeaders).toBeUndefined(); @@ -67,6 +70,7 @@ describe('AuthenticationResult', () => { expect(authenticationResult.authResponseHeaders).toEqual({ 'WWW-Authenticate': 'Negotiate' }); expect(authenticationResult.error).toBe(failureReason); expect(authenticationResult.user).toBeUndefined(); + expect(authenticationResult.userProfileGrant).toBeUndefined(); expect(authenticationResult.state).toBeUndefined(); expect(authenticationResult.authHeaders).toBeUndefined(); expect(authenticationResult.redirectURL).toBeUndefined(); @@ -80,7 +84,7 @@ describe('AuthenticationResult', () => { ); }); - it('correctly produces `succeeded` authentication result without state and auth headers.', () => { + it('correctly produces `succeeded` authentication result without state, auth headers and user profile grant.', () => { const user = mockAuthenticatedUser(); const authenticationResult = AuthenticationResult.succeeded(user); @@ -90,6 +94,7 @@ describe('AuthenticationResult', () => { expect(authenticationResult.redirected()).toBe(false); expect(authenticationResult.user).toBe(user); + expect(authenticationResult.userProfileGrant).toBeUndefined(); expect(authenticationResult.state).toBeUndefined(); expect(authenticationResult.authHeaders).toBeUndefined(); expect(authenticationResult.authResponseHeaders).toBeUndefined(); @@ -97,7 +102,7 @@ describe('AuthenticationResult', () => { expect(authenticationResult.redirectURL).toBeUndefined(); }); - it('correctly produces `succeeded` authentication result with state, but without auth headers.', () => { + it('correctly produces `succeeded` authentication result with state, but without user profile grant and auth headers.', () => { const user = mockAuthenticatedUser(); const state = { some: 'state' }; const authenticationResult = AuthenticationResult.succeeded(user, { state }); @@ -109,13 +114,40 @@ describe('AuthenticationResult', () => { expect(authenticationResult.user).toBe(user); expect(authenticationResult.state).toBe(state); + expect(authenticationResult.userProfileGrant).toBeUndefined(); expect(authenticationResult.authHeaders).toBeUndefined(); expect(authenticationResult.authResponseHeaders).toBeUndefined(); expect(authenticationResult.error).toBeUndefined(); expect(authenticationResult.redirectURL).toBeUndefined(); }); - it('correctly produces `succeeded` authentication result with auth headers, but without state.', () => { + it('correctly produces `succeeded` authentication result with state and user profile grant, but without auth headers.', () => { + const user = mockAuthenticatedUser(); + const state = { some: 'state' }; + const userProfileGrant = { + type: 'accessToken' as 'accessToken', + accessToken: 'access-token', + }; + const authenticationResult = AuthenticationResult.succeeded(user, { + userProfileGrant, + state, + }); + + expect(authenticationResult.succeeded()).toBe(true); + expect(authenticationResult.failed()).toBe(false); + expect(authenticationResult.notHandled()).toBe(false); + expect(authenticationResult.redirected()).toBe(false); + + expect(authenticationResult.user).toBe(user); + expect(authenticationResult.state).toBe(state); + expect(authenticationResult.userProfileGrant).toBe(userProfileGrant); + expect(authenticationResult.authHeaders).toBeUndefined(); + expect(authenticationResult.authResponseHeaders).toBeUndefined(); + expect(authenticationResult.error).toBeUndefined(); + expect(authenticationResult.redirectURL).toBeUndefined(); + }); + + it('correctly produces `succeeded` authentication result with auth headers, but without state and user profile grant.', () => { const user = mockAuthenticatedUser(); const authHeaders = { authorization: 'some-token' }; const authResponseHeaders = { 'WWW-Authenticate': 'Negotiate' }; @@ -130,6 +162,7 @@ describe('AuthenticationResult', () => { expect(authenticationResult.redirected()).toBe(false); expect(authenticationResult.user).toBe(user); + expect(authenticationResult.userProfileGrant).toBeUndefined(); expect(authenticationResult.state).toBeUndefined(); expect(authenticationResult.authHeaders).toBe(authHeaders); expect(authenticationResult.authResponseHeaders).toBe(authResponseHeaders); @@ -137,14 +170,20 @@ describe('AuthenticationResult', () => { expect(authenticationResult.redirectURL).toBeUndefined(); }); - it('correctly produces `succeeded` authentication result with both auth headers and state.', () => { + it('correctly produces `succeeded` authentication result with auth headers, state and user profile grant.', () => { const user = mockAuthenticatedUser(); const authHeaders = { authorization: 'some-token' }; const authResponseHeaders = { 'WWW-Authenticate': 'Negotiate' }; const state = { some: 'state' }; + const userProfileGrant: UserProfileGrant = { + type: 'password', + username: 'user', + password: 'password', + }; const authenticationResult = AuthenticationResult.succeeded(user, { authHeaders, authResponseHeaders, + userProfileGrant, state, }); @@ -157,6 +196,7 @@ describe('AuthenticationResult', () => { expect(authenticationResult.state).toBe(state); expect(authenticationResult.authHeaders).toBe(authHeaders); expect(authenticationResult.authResponseHeaders).toBe(authResponseHeaders); + expect(authenticationResult.userProfileGrant).toBe(userProfileGrant); expect(authenticationResult.error).toBeUndefined(); expect(authenticationResult.redirectURL).toBeUndefined(); }); @@ -169,7 +209,7 @@ describe('AuthenticationResult', () => { ); }); - it('correctly produces `redirected` authentication result without state, user and response headers.', () => { + it('correctly produces `redirected` authentication result without state, user, user profile grant and response headers.', () => { const redirectURL = '/redirect/url'; const authenticationResult = AuthenticationResult.redirectTo(redirectURL); @@ -183,6 +223,7 @@ describe('AuthenticationResult', () => { expect(authenticationResult.state).toBeUndefined(); expect(authenticationResult.authHeaders).toBeUndefined(); expect(authenticationResult.authResponseHeaders).toBeUndefined(); + expect(authenticationResult.userProfileGrant).toBeUndefined(); expect(authenticationResult.error).toBeUndefined(); }); @@ -200,6 +241,7 @@ describe('AuthenticationResult', () => { expect(authenticationResult.state).toBe(state); expect(authenticationResult.authHeaders).toBeUndefined(); expect(authenticationResult.authResponseHeaders).toBeUndefined(); + expect(authenticationResult.userProfileGrant).toBeUndefined(); expect(authenticationResult.user).toBeUndefined(); expect(authenticationResult.error).toBeUndefined(); }); @@ -219,17 +261,24 @@ describe('AuthenticationResult', () => { expect(authenticationResult.state).toBe(state); expect(authenticationResult.authHeaders).toBeUndefined(); expect(authenticationResult.authResponseHeaders).toBeUndefined(); + expect(authenticationResult.userProfileGrant).toBeUndefined(); expect(authenticationResult.user).toBe(user); expect(authenticationResult.error).toBeUndefined(); }); - it('correctly produces `redirected` authentication result with state, user and response headers.', () => { + it('correctly produces `redirected` authentication result with state, user, user profile grant and response headers.', () => { const redirectURL = '/redirect/url'; const state = { some: 'state' }; const user = mockAuthenticatedUser(); const authResponseHeaders = { 'WWW-Authenticate': 'Negotiate' }; + const userProfileGrant: UserProfileGrant = { + type: 'password', + username: 'user', + password: 'password', + }; const authenticationResult = AuthenticationResult.redirectTo(redirectURL, { user, + userProfileGrant, state, authResponseHeaders, }); @@ -244,6 +293,7 @@ describe('AuthenticationResult', () => { expect(authenticationResult.authHeaders).toBeUndefined(); expect(authenticationResult.authResponseHeaders).toBe(authResponseHeaders); expect(authenticationResult.user).toBe(user); + expect(authenticationResult.userProfileGrant).toBe(userProfileGrant); expect(authenticationResult.error).toBeUndefined(); }); }); diff --git a/x-pack/plugins/security/server/authentication/authentication_result.ts b/x-pack/plugins/security/server/authentication/authentication_result.ts index a5b1e11754d97..42b4e5116bb08 100644 --- a/x-pack/plugins/security/server/authentication/authentication_result.ts +++ b/x-pack/plugins/security/server/authentication/authentication_result.ts @@ -7,7 +7,8 @@ import type { AuthHeaders } from 'src/core/server'; -import type { AuthenticatedUser } from '../../common/model'; +import type { AuthenticatedUser } from '../../common'; +import type { UserProfileGrant } from '../user_profile'; /** * Represents status that `AuthenticationResult` can be in. @@ -49,8 +50,21 @@ interface AuthenticationOptions { user?: AuthenticatedUser; authHeaders?: AuthHeaders; authResponseHeaders?: AuthHeaders; + userProfileGrant?: UserProfileGrant; } +export type SucceededAuthenticationResultOptions = Pick< + AuthenticationOptions, + 'authHeaders' | 'userProfileGrant' | 'authResponseHeaders' | 'state' +>; + +export type RedirectedAuthenticationResultOptions = Pick< + AuthenticationOptions, + 'user' | 'userProfileGrant' | 'authResponseHeaders' | 'state' +>; + +export type FailedAuthenticationResultOptions = Pick; + /** * Represents the result of an authentication attempt. */ @@ -66,6 +80,7 @@ export class AuthenticationResult { /** * Produces `AuthenticationResult` for the case when authentication succeeds. * @param user User information retrieved as a result of successful authentication attempt. + * @param [userProfileGrant] Optional user profile grant that can be used to activate user profile. * @param [authHeaders] Optional dictionary of the HTTP headers with authentication information. * @param [authResponseHeaders] Optional dictionary of the HTTP headers with authentication * information that should be specified in the response we send to the client request. @@ -74,10 +89,11 @@ export class AuthenticationResult { public static succeeded( user: AuthenticatedUser, { + userProfileGrant, authHeaders, authResponseHeaders, state, - }: Pick = {} + }: SucceededAuthenticationResultOptions = {} ) { if (!user) { throw new Error('User should be specified.'); @@ -85,6 +101,7 @@ export class AuthenticationResult { return new AuthenticationResult(AuthenticationResultStatus.Succeeded, { user, + userProfileGrant, authHeaders, authResponseHeaders, state, @@ -100,7 +117,7 @@ export class AuthenticationResult { */ public static failed( error: Error, - { authResponseHeaders }: Pick = {} + { authResponseHeaders }: FailedAuthenticationResultOptions = {} ) { if (!error) { throw new Error('Error should be specified.'); @@ -116,6 +133,7 @@ export class AuthenticationResult { * Produces `AuthenticationResult` for the case when authentication needs user to be redirected. * @param redirectURL URL that should be used to redirect user to complete authentication. * @param [user] Optional user information retrieved as a result of successful authentication attempt. + * @param [userProfileGrant] Optional user profile grant that can be used to activate user profile. * @param [authResponseHeaders] Optional dictionary of the HTTP headers with authentication * information that should be specified in the response we send to the client request. * @param [state] Optional state to be stored and reused for the next request. @@ -124,9 +142,10 @@ export class AuthenticationResult { redirectURL: string, { user, + userProfileGrant, authResponseHeaders, state, - }: Pick = {} + }: RedirectedAuthenticationResultOptions = {} ) { if (!redirectURL) { throw new Error('Redirect URL must be specified.'); @@ -135,18 +154,26 @@ export class AuthenticationResult { return new AuthenticationResult(AuthenticationResultStatus.Redirected, { redirectURL, user, + userProfileGrant, authResponseHeaders, state, }); } /** - * Authenticated user instance (only available for `succeeded` result). + * Authenticated user instance (only available for `succeeded` or `redirected` result). */ public get user() { return this.options.user; } + /** + * User profile grant that can be used to activate user profile (only available for `succeeded` and `redirected` results). + */ + public get userProfileGrant() { + return this.options.userProfileGrant; + } + /** * Headers that include authentication information that should be used to authenticate user for any * future requests (only available for `succeeded` result). @@ -169,8 +196,7 @@ export class AuthenticationResult { } /** - * State associated with the authenticated user (only available for `succeeded` - * and `redirected` results). + * State associated with the authenticated user (only available for `succeeded` and `redirected` results). */ public get state() { return this.options.state; @@ -184,8 +210,7 @@ export class AuthenticationResult { } /** - * URL that should be used to redirect user to complete authentication only available - * for `redirected` result). + * URL that should be used to redirect user to complete authentication (only available for `redirected` result). */ public get redirectURL() { return this.options.redirectURL; diff --git a/x-pack/plugins/security/server/authentication/authentication_service.test.ts b/x-pack/plugins/security/server/authentication/authentication_service.test.ts index b740a2a2db085..72e3de850eb8d 100644 --- a/x-pack/plugins/security/server/authentication/authentication_service.test.ts +++ b/x-pack/plugins/security/server/authentication/authentication_service.test.ts @@ -48,6 +48,7 @@ import { securityMock } from '../mocks'; import { ROUTE_TAG_AUTH_FLOW } from '../routes/tags'; import type { Session } from '../session_management'; import { sessionMock } from '../session_management/session.mock'; +import { userProfileServiceMock } from '../user_profile/user_profile_service.mock'; import { AuthenticationResult } from './authentication_result'; import { AuthenticationService } from './authentication_service'; @@ -68,6 +69,7 @@ describe('AuthenticationService', () => { http: jest.Mocked; clusterClient: ReturnType; featureUsageService: jest.Mocked; + userProfileService: ReturnType; session: jest.Mocked>; }; beforeEach(() => { @@ -107,6 +109,7 @@ describe('AuthenticationService', () => { loggers: loggingSystemMock.create(), featureUsageService: securityFeatureUsageServiceMock.createStartContract(), session: sessionMock.create(), + userProfileService: userProfileServiceMock.createStart(), }; (mockStartAuthenticationParams.http.basePath.get as jest.Mock).mockImplementation( () => mockStartAuthenticationParams.http.basePath.serverBasePath diff --git a/x-pack/plugins/security/server/authentication/authentication_service.ts b/x-pack/plugins/security/server/authentication/authentication_service.ts index 96dcf23af4fd0..e76a29287bfa1 100644 --- a/x-pack/plugins/security/server/authentication/authentication_service.ts +++ b/x-pack/plugins/security/server/authentication/authentication_service.ts @@ -25,6 +25,7 @@ import { getDetailedErrorMessage, getErrorStatusCode } from '../errors'; import type { SecurityFeatureUsageServiceStart } from '../feature_usage'; import { ROUTE_TAG_AUTH_FLOW } from '../routes/tags'; import type { Session } from '../session_management'; +import type { UserProfileServiceStart } from '../user_profile'; import { APIKeys } from './api_keys'; import type { AuthenticationResult } from './authentication_result'; import type { ProviderLoginAttempt } from './authenticator'; @@ -47,6 +48,7 @@ interface AuthenticationServiceStartParams { clusterClient: IClusterClient; audit: AuditServiceSetup; featureUsageService: SecurityFeatureUsageServiceStart; + userProfileService: UserProfileServiceStart; session: PublicMethodsOf; loggers: LoggerFactory; } @@ -293,6 +295,7 @@ export class AuthenticationService { config, clusterClient, featureUsageService, + userProfileService, http, loggers, session, @@ -326,6 +329,7 @@ export class AuthenticationService { config: { authc: config.authc }, getCurrentUser, featureUsageService, + userProfileService, getServerBaseURL, license: this.license, session, diff --git a/x-pack/plugins/security/server/authentication/authenticator.test.ts b/x-pack/plugins/security/server/authentication/authenticator.test.ts index 1559a6fa4a5d7..aaf0d27558a41 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.test.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.test.ts @@ -20,11 +20,11 @@ import { loggingSystemMock, } from 'src/core/server/mocks'; +import type { SecurityLicenseFeatures } from '../../common'; import { AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER, AUTH_URL_HASH_QUERY_STRING_PARAMETER, } from '../../common/constants'; -import type { SecurityLicenseFeatures } from '../../common/licensing'; import { licenseMock } from '../../common/licensing/index.mock'; import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock'; import type { AuditLogger } from '../audit'; @@ -34,6 +34,9 @@ import { securityFeatureUsageServiceMock } from '../feature_usage/index.mock'; import { securityMock } from '../mocks'; import type { SessionValue } from '../session_management'; import { sessionMock } from '../session_management/index.mock'; +import type { UserProfileGrant } from '../user_profile'; +import { userProfileMock } from '../user_profile/user_profile.mock'; +import { userProfileServiceMock } from '../user_profile/user_profile_service.mock'; import { AuthenticationResult } from './authentication_result'; import type { AuthenticatorOptions } from './authenticator'; import { Authenticator } from './authenticator'; @@ -68,6 +71,7 @@ function getMockOptions({ ), session: sessionMock.create(), featureUsageService: securityFeatureUsageServiceMock.createStartContract(), + userProfileService: userProfileServiceMock.createStart(), }; } @@ -430,6 +434,39 @@ describe('Authenticator', () => { state: { authorization }, }); expectAuditEvents({ action: 'user_login', outcome: 'success' }); + expect(mockOptions.userProfileService.activate).not.toHaveBeenCalled(); + }); + + it('activates profile whenever authentication provider returns user profile grant', async () => { + const user = mockAuthenticatedUser(); + const request = httpServerMock.createKibanaRequest(); + const authorization = `Basic ${Buffer.from('foo:bar').toString('base64')}`; + const userProfileGrant: UserProfileGrant = { + type: 'password', + username: 'some-user', + password: 'some-password', + }; + + mockBasicAuthenticationProvider.login.mockResolvedValue( + AuthenticationResult.succeeded(user, { userProfileGrant, state: { authorization } }) + ); + + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: {} }) + ).resolves.toEqual( + AuthenticationResult.succeeded(user, { userProfileGrant, state: { authorization } }) + ); + + expect(mockOptions.session.create).toHaveBeenCalledTimes(1); + expect(mockOptions.session.create).toHaveBeenCalledWith(request, { + username: user.username, + userProfileId: 'some-profile-uid', + provider: mockSessVal.provider, + state: { authorization }, + }); + expectAuditEvents({ action: 'user_login', outcome: 'success' }); + expect(mockOptions.userProfileService.activate).toHaveBeenCalledTimes(1); + expect(mockOptions.userProfileService.activate).toHaveBeenCalledWith(userProfileGrant); }); it('returns `notHandled` if login attempt is targeted to not configured provider.', async () => { @@ -1252,6 +1289,7 @@ describe('Authenticator', () => { state: { authorization }, }); expect(auditLogger.log).not.toHaveBeenCalled(); + expect(mockOptions.userProfileService.activate).not.toHaveBeenCalled(); }); it('creates session whenever authentication provider returns state for non-system API requests', async () => { @@ -1276,6 +1314,39 @@ describe('Authenticator', () => { state: { authorization }, }); expect(auditLogger.log).not.toHaveBeenCalled(); + expect(mockOptions.userProfileService.activate).not.toHaveBeenCalled(); + }); + + it('activates user profile whenever authentication provider returns user profile grant for non-system API requests', async () => { + const user = mockAuthenticatedUser(); + const request = httpServerMock.createKibanaRequest({ + headers: { 'kbn-system-request': 'false' }, + }); + const authorization = `Basic ${Buffer.from('foo:bar').toString('base64')}`; + const userProfileGrant: UserProfileGrant = { + type: 'password', + username: 'some-user', + password: 'some-password', + }; + + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.succeeded(user, { userProfileGrant, state: { authorization } }) + ); + + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.succeeded(user, { userProfileGrant, state: { authorization } }) + ); + + expect(mockOptions.session.create).toHaveBeenCalledTimes(1); + expect(mockOptions.session.create).toHaveBeenCalledWith(request, { + username: user.username, + userProfileId: 'some-profile-uid', + provider: mockSessVal.provider, + state: { authorization }, + }); + expect(auditLogger.log).not.toHaveBeenCalled(); + expect(mockOptions.userProfileService.activate).toHaveBeenCalledTimes(1); + expect(mockOptions.userProfileService.activate).toHaveBeenCalledWith(userProfileGrant); }); it('does not extend session for system API calls.', async () => { @@ -1392,6 +1463,7 @@ describe('Authenticator', () => { expect(mockOptions.session.extend).not.toHaveBeenCalled(); expect(mockOptions.session.invalidate).not.toHaveBeenCalled(); expect(auditLogger.log).not.toHaveBeenCalled(); + expect(mockOptions.userProfileService.activate).not.toHaveBeenCalled(); }); it('replaces existing session with the one returned by authentication provider for non-system API requests', async () => { @@ -1419,6 +1491,46 @@ describe('Authenticator', () => { expect(mockOptions.session.extend).not.toHaveBeenCalled(); expect(mockOptions.session.invalidate).not.toHaveBeenCalled(); expect(auditLogger.log).not.toHaveBeenCalled(); + expect(mockOptions.userProfileService.activate).not.toHaveBeenCalled(); + }); + + it('re-activates user profile if authentication provider returns a user profile grant for non-system API requests', async () => { + const user = mockAuthenticatedUser(); + const newState = { authorization: 'Basic yyy' }; + const request = httpServerMock.createKibanaRequest({ + headers: { 'kbn-system-request': 'false' }, + }); + const userProfileGrant: UserProfileGrant = { + type: 'password', + username: 'some-user', + password: 'some-password', + }; + mockOptions.userProfileService.activate.mockResolvedValue({ + ...userProfileMock.create(), + uid: 'new-profile-uid', + }); + + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.succeeded(user, { userProfileGrant, state: newState }) + ); + mockOptions.session.get.mockResolvedValue(mockSessVal); + + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.succeeded(user, { userProfileGrant, state: newState }) + ); + + expect(mockOptions.session.update).toHaveBeenCalledTimes(1); + expect(mockOptions.session.update).toHaveBeenCalledWith(request, { + ...mockSessVal, + userProfileId: 'new-profile-uid', + state: newState, + }); + expect(mockOptions.session.create).not.toHaveBeenCalled(); + expect(mockOptions.session.extend).not.toHaveBeenCalled(); + expect(mockOptions.session.invalidate).not.toHaveBeenCalled(); + expect(auditLogger.log).not.toHaveBeenCalled(); + expect(mockOptions.userProfileService.activate).toHaveBeenCalledTimes(1); + expect(mockOptions.userProfileService.activate).toHaveBeenCalledWith(userProfileGrant); }); it('clears session if provider failed to authenticate system API request with 401 with active session.', async () => { diff --git a/x-pack/plugins/security/server/authentication/authenticator.ts b/x-pack/plugins/security/server/authentication/authenticator.ts index 3cddcd48dda7e..795a7c205d67f 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.ts @@ -10,6 +10,7 @@ import type { PublicMethodsOf } from '@kbn/utility-types'; import type { IBasePath, IClusterClient, LoggerFactory } from 'src/core/server'; import { KibanaRequest } from '../../../../../src/core/server'; +import type { AuthenticatedUser, AuthenticationProvider, SecurityLicense } from '../../common'; import { AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER, AUTH_URL_HASH_QUERY_STRING_PARAMETER, @@ -17,8 +18,6 @@ import { LOGOUT_REASON_QUERY_STRING_PARAMETER, NEXT_URL_QUERY_STRING_PARAMETER, } from '../../common/constants'; -import type { SecurityLicense } from '../../common/licensing'; -import type { AuthenticatedUser, AuthenticationProvider } from '../../common/model'; import { shouldProviderUseLoginForm } from '../../common/model'; import type { AuditServiceSetup } from '../audit'; import { accessAgreementAcknowledgedEvent, userLoginEvent, userLogoutEvent } from '../audit'; @@ -26,6 +25,7 @@ import type { ConfigType } from '../config'; import { getErrorStatusCode } from '../errors'; import type { SecurityFeatureUsageServiceStart } from '../feature_usage'; import type { Session, SessionValue } from '../session_management'; +import type { UserProfileServiceStart } from '../user_profile'; import { AuthenticationResult } from './authentication_result'; import { canRedirectRequest } from './can_redirect_request'; import { DeauthenticationResult } from './deauthentication_result'; @@ -80,6 +80,7 @@ export interface ProviderLoginAttempt { export interface AuthenticatorOptions { audit: AuditServiceSetup; featureUsageService: SecurityFeatureUsageServiceStart; + userProfileService: UserProfileServiceStart; getCurrentUser: (request: KibanaRequest) => AuthenticatedUser | null; config: Pick; basePath: IBasePath; @@ -712,16 +713,35 @@ export class Authenticator { existingSessionValue = null; } + // If authentication result includes user profile grant, we should try to activate user profile for this user and + // store user profile identifier in the session value. + let userProfileId = existingSessionValue?.userProfileId; + if (authenticationResult.userProfileGrant) { + this.logger.debug(`Activating profile for "${authenticationResult.user?.username}".`); + userProfileId = ( + await this.options.userProfileService.activate(authenticationResult.userProfileGrant) + ).uid; + + if ( + existingSessionValue?.userProfileId && + existingSessionValue.userProfileId !== userProfileId + ) { + this.logger.warn(`User profile for "${authenticationResult.user?.username}" has changed.`); + } + } + let newSessionValue; if (!existingSessionValue) { newSessionValue = await this.session.create(request, { username: authenticationResult.user?.username, + userProfileId, provider, state: authenticationResult.shouldUpdateState() ? authenticationResult.state : null, }); } else if (authenticationResult.shouldUpdateState()) { newSessionValue = await this.session.update(request, { ...existingSessionValue, + userProfileId, state: authenticationResult.shouldUpdateState() ? authenticationResult.state : existingSessionValue.state, diff --git a/x-pack/plugins/security/server/authentication/providers/basic.test.ts b/x-pack/plugins/security/server/authentication/providers/basic.test.ts index 1a4998bf27cbf..e8123dae3bb54 100644 --- a/x-pack/plugins/security/server/authentication/providers/basic.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/basic.test.ts @@ -57,6 +57,7 @@ describe('BasicAuthenticationProvider', () => { ).resolves.toEqual( AuthenticationResult.succeeded(user, { authHeaders: { authorization }, + userProfileGrant: { type: 'password', username: 'user', password: 'password' }, state: { authorization }, }) ); diff --git a/x-pack/plugins/security/server/authentication/providers/basic.ts b/x-pack/plugins/security/server/authentication/providers/basic.ts index d0a3a90cae908..05f840ef21dca 100644 --- a/x-pack/plugins/security/server/authentication/providers/basic.ts +++ b/x-pack/plugins/security/server/authentication/providers/basic.ts @@ -80,7 +80,11 @@ export class BasicAuthenticationProvider extends BaseAuthenticationProvider { const user = await this.getUser(request, authHeaders); this.logger.debug('Login has been successfully performed.'); - return AuthenticationResult.succeeded(user, { authHeaders, state: authHeaders }); + return AuthenticationResult.succeeded(user, { + userProfileGrant: { type: 'password', username, password }, + authHeaders, + state: authHeaders, + }); } catch (err) { this.logger.debug(`Failed to perform a login: ${err.message}`); return AuthenticationResult.failed(err); diff --git a/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts b/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts index 91475a845d8b0..c2cb30f76bad6 100644 --- a/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts @@ -135,6 +135,7 @@ describe('KerberosAuthenticationProvider', () => { { ...user, authentication_provider: { type: 'kerberos', name: 'kerberos' } }, { authHeaders: { authorization: 'Bearer some-token' }, + userProfileGrant: { type: 'accessToken', accessToken: 'some-token' }, state: { accessToken: 'some-token', refreshToken: 'some-refresh-token' }, } ) @@ -170,6 +171,7 @@ describe('KerberosAuthenticationProvider', () => { { authHeaders: { authorization: 'Bearer some-token' }, authResponseHeaders: { 'WWW-Authenticate': 'Negotiate response-token' }, + userProfileGrant: { type: 'accessToken', accessToken: 'some-token' }, state: { accessToken: 'some-token', refreshToken: 'some-refresh-token' }, } ) diff --git a/x-pack/plugins/security/server/authentication/providers/kerberos.ts b/x-pack/plugins/security/server/authentication/providers/kerberos.ts index 5cb1b01dddf9a..c129ee3e7c724 100644 --- a/x-pack/plugins/security/server/authentication/providers/kerberos.ts +++ b/x-pack/plugins/security/server/authentication/providers/kerberos.ts @@ -217,6 +217,7 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { return AuthenticationResult.succeeded( this.authenticationInfoToAuthenticatedUser(tokens.authentication), { + userProfileGrant: { type: 'accessToken', accessToken: tokens.access_token }, authHeaders: { authorization: new HTTPAuthorizationHeader('Bearer', tokens.access_token).toString(), }, diff --git a/x-pack/plugins/security/server/authentication/providers/oidc.test.ts b/x-pack/plugins/security/server/authentication/providers/oidc.test.ts index c21327fe0674a..6025b5a2c90ad 100644 --- a/x-pack/plugins/security/server/authentication/providers/oidc.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/oidc.test.ts @@ -201,6 +201,7 @@ describe('OIDCAuthenticationProvider', () => { }) ).resolves.toEqual( AuthenticationResult.redirectTo('/base-path/some-path', { + userProfileGrant: { type: 'accessToken', accessToken: 'some-token' }, state: { accessToken: 'some-token', refreshToken: 'some-refresh-token', diff --git a/x-pack/plugins/security/server/authentication/providers/oidc.ts b/x-pack/plugins/security/server/authentication/providers/oidc.ts index cad10be6b4aa2..79299d56ecbca 100644 --- a/x-pack/plugins/security/server/authentication/providers/oidc.ts +++ b/x-pack/plugins/security/server/authentication/providers/oidc.ts @@ -275,12 +275,13 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { this.logger.debug('Login has been performed with OpenID Connect response.'); return AuthenticationResult.redirectTo(stateRedirectURL, { + user: this.authenticationInfoToAuthenticatedUser(result.authentication), + userProfileGrant: { type: 'accessToken', accessToken: result.access_token }, state: { accessToken: result.access_token, refreshToken: result.refresh_token, realm: this.realm, }, - user: this.authenticationInfoToAuthenticatedUser(result.authentication), }); } diff --git a/x-pack/plugins/security/server/authentication/providers/pki.test.ts b/x-pack/plugins/security/server/authentication/providers/pki.test.ts index 04d7013e496b7..6c6d41569f609 100644 --- a/x-pack/plugins/security/server/authentication/providers/pki.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/pki.test.ts @@ -261,6 +261,7 @@ describe('PKIAuthenticationProvider', () => { { ...user, authentication_provider: { type: 'pki', name: 'pki' } }, { authHeaders: { authorization: 'Bearer access-token' }, + userProfileGrant: { type: 'accessToken', accessToken: 'access-token' }, state: { accessToken: 'access-token', peerCertificateFingerprint256: '2A:7A:C2:DD' }, } ) @@ -305,6 +306,7 @@ describe('PKIAuthenticationProvider', () => { { ...user, authentication_provider: { type: 'pki', name: 'pki' } }, { authHeaders: { authorization: 'Bearer access-token' }, + userProfileGrant: { type: 'accessToken', accessToken: 'access-token' }, state: { accessToken: 'access-token', peerCertificateFingerprint256: '2A:7A:C2:DD' }, } ) @@ -342,6 +344,7 @@ describe('PKIAuthenticationProvider', () => { { ...user, authentication_provider: { type: 'pki', name: 'pki' } }, { authHeaders: { authorization: 'Bearer access-token' }, + userProfileGrant: { type: 'accessToken', accessToken: 'access-token' }, state: { accessToken: 'access-token', peerCertificateFingerprint256: '2A:7A:C2:DD' }, } ) @@ -508,6 +511,7 @@ describe('PKIAuthenticationProvider', () => { { ...user, authentication_provider: { type: 'pki', name: 'pki' } }, { authHeaders: { authorization: 'Bearer access-token' }, + userProfileGrant: { type: 'accessToken', accessToken: 'access-token' }, state: { accessToken: 'access-token', peerCertificateFingerprint256: '2A:7A:C2:DD' }, } ) diff --git a/x-pack/plugins/security/server/authentication/providers/pki.ts b/x-pack/plugins/security/server/authentication/providers/pki.ts index 1bb02671b97be..809dc205d2c70 100644 --- a/x-pack/plugins/security/server/authentication/providers/pki.ts +++ b/x-pack/plugins/security/server/authentication/providers/pki.ts @@ -42,7 +42,7 @@ interface CertificateChain { /** * List of protocols that can be renegotiated. Notably, TLSv1.3 is absent from this list, because it does not support renegotiation. */ -const RENEGOTIATABLE_PROTOCOLS = ['TLSv1', 'TLSv1.1', 'TLSv1.2']; +const RENEGOTIABLE_PROTOCOLS = ['TLSv1', 'TLSv1.1', 'TLSv1.2']; /** * Checks whether current request can initiate new session. @@ -133,7 +133,7 @@ export class PKIAuthenticationProvider extends BaseAuthenticationProvider { (authenticationResult.failed() && Tokens.isAccessTokenExpiredError(authenticationResult.error)); if (invalidAccessToken) { - authenticationResult = await this.authenticateViaPeerCertificate(request); + authenticationResult = await this.authenticateViaPeerCertificate(request, state); // If we have an active session that we couldn't use to authenticate user and at the same time // we couldn't use peer's certificate to establish a new one, then we should respond with 401 // and force authenticator to clear the session. @@ -147,7 +147,7 @@ export class PKIAuthenticationProvider extends BaseAuthenticationProvider { // to start a new session, and if so try to authenticate request using its peer certificate chain, // otherwise just return authentication result we have. return authenticationResult.notHandled() && canStartNewSession(request) - ? await this.authenticateViaPeerCertificate(request) + ? await this.authenticateViaPeerCertificate(request, state) : authenticationResult; } @@ -247,8 +247,12 @@ export class PKIAuthenticationProvider extends BaseAuthenticationProvider { /** * Tries to exchange peer certificate chain to access/refresh token pair. * @param request Request instance. + * @param [state] Optional state object associated with the provider. */ - private async authenticateViaPeerCertificate(request: KibanaRequest) { + private async authenticateViaPeerCertificate( + request: KibanaRequest, + state?: ProviderState | null + ) { this.logger.debug('Trying to authenticate request via peer certificate chain.'); // We should collect entire certificate chain as an ordered array of certificates encoded as base64 strings. @@ -293,6 +297,11 @@ export class PKIAuthenticationProvider extends BaseAuthenticationProvider { return AuthenticationResult.succeeded( this.authenticationInfoToAuthenticatedUser(result.authentication), { + // There is no need to re-activate user profile if client certificate hasn't changed. + userProfileGrant: + peerCertificate.fingerprint256 !== state?.peerCertificateFingerprint256 + ? { type: 'accessToken', accessToken: result.access_token } + : undefined, authHeaders: { authorization: new HTTPAuthorizationHeader('Bearer', result.access_token).toString(), }, @@ -311,6 +320,7 @@ export class PKIAuthenticationProvider extends BaseAuthenticationProvider { * (root/self-signed certificate) or when `issuerCertificate` isn't available (null or empty object). Automatically attempts to * renegotiate the TLS connection once if the peer certificate chain is incomplete. * @param request Request instance. + * @param isRenegotiated Indicates whether connection has been already renegotiated. */ private async getCertificateChain( request: KibanaRequest, @@ -332,13 +342,13 @@ export class PKIAuthenticationProvider extends BaseAuthenticationProvider { break; } else if (certificate.issuerCertificate === undefined) { const protocol = request.socket.getProtocol(); - if (!isRenegotiated && protocol && RENEGOTIATABLE_PROTOCOLS.includes(protocol)) { + if (!isRenegotiated && protocol && RENEGOTIABLE_PROTOCOLS.includes(protocol)) { this.logger.debug( `Detected incomplete certificate chain with protocol '${protocol}', attempting to renegotiate connection.` ); try { await request.socket.renegotiate({ requestCert: true, rejectUnauthorized: false }); - return this.getCertificateChain(request, true); + return this.getCertificateChain(request, true /* isRenegotiated */); } catch (err) { this.logger.debug(`Failed to renegotiate connection: ${err}.`); } diff --git a/x-pack/plugins/security/server/authentication/providers/saml.test.ts b/x-pack/plugins/security/server/authentication/providers/saml.test.ts index 046ae16c7e318..1f7be4b83062c 100644 --- a/x-pack/plugins/security/server/authentication/providers/saml.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/saml.test.ts @@ -63,6 +63,7 @@ describe('SAMLAuthenticationProvider', () => { ) ).resolves.toEqual( AuthenticationResult.redirectTo('/test-base-path/some-path#some-app', { + userProfileGrant: { type: 'accessToken', accessToken: 'some-token' }, state: { accessToken: 'some-token', refreshToken: 'some-refresh-token', @@ -108,6 +109,7 @@ describe('SAMLAuthenticationProvider', () => { ) ).resolves.toEqual( AuthenticationResult.redirectTo('/test-base-path/some-path#some-app', { + userProfileGrant: { type: 'accessToken', accessToken: 'some-token' }, state: { accessToken: 'some-token', refreshToken: 'some-refresh-token', @@ -184,6 +186,7 @@ describe('SAMLAuthenticationProvider', () => { ) ).resolves.toEqual( AuthenticationResult.redirectTo('/mock-server-basepath/', { + userProfileGrant: { type: 'accessToken', accessToken: 'user-initiated-login-token' }, state: { accessToken: 'user-initiated-login-token', refreshToken: 'user-initiated-login-refresh-token', @@ -225,6 +228,7 @@ describe('SAMLAuthenticationProvider', () => { ) ).resolves.toEqual( AuthenticationResult.redirectTo('/mock-server-basepath/', { + userProfileGrant: { type: 'accessToken', accessToken: 'user-initiated-login-token' }, state: { accessToken: 'user-initiated-login-token', refreshToken: 'user-initiated-login-refresh-token', @@ -258,6 +262,7 @@ describe('SAMLAuthenticationProvider', () => { }) ).resolves.toEqual( AuthenticationResult.redirectTo('/mock-server-basepath/', { + userProfileGrant: { type: 'accessToken', accessToken: 'idp-initiated-login-token' }, state: { accessToken: 'idp-initiated-login-token', refreshToken: 'idp-initiated-login-refresh-token', @@ -331,6 +336,7 @@ describe('SAMLAuthenticationProvider', () => { }) ).resolves.toEqual( AuthenticationResult.redirectTo(`${mockOptions.basePath.serverBasePath}/`, { + userProfileGrant: { type: 'accessToken', accessToken: 'valid-token' }, state: { accessToken: 'valid-token', refreshToken: 'valid-refresh-token', @@ -349,6 +355,7 @@ describe('SAMLAuthenticationProvider', () => { }) ).resolves.toEqual( AuthenticationResult.redirectTo(`${mockOptions.basePath.serverBasePath}/`, { + userProfileGrant: { type: 'accessToken', accessToken: 'valid-token' }, state: { accessToken: 'valid-token', refreshToken: 'valid-refresh-token', @@ -368,6 +375,7 @@ describe('SAMLAuthenticationProvider', () => { }) ).resolves.toEqual( AuthenticationResult.redirectTo(`${mockOptions.basePath.serverBasePath}/`, { + userProfileGrant: { type: 'accessToken', accessToken: 'valid-token' }, state: { accessToken: 'valid-token', refreshToken: 'valid-refresh-token', @@ -387,6 +395,7 @@ describe('SAMLAuthenticationProvider', () => { }) ).resolves.toEqual( AuthenticationResult.redirectTo(`${mockOptions.basePath.serverBasePath}/`, { + userProfileGrant: { type: 'accessToken', accessToken: 'valid-token' }, state: { accessToken: 'valid-token', refreshToken: 'valid-refresh-token', @@ -408,6 +417,7 @@ describe('SAMLAuthenticationProvider', () => { AuthenticationResult.redirectTo( `${mockOptions.basePath.serverBasePath}/app/some-app#some-deep-link`, { + userProfileGrant: { type: 'accessToken', accessToken: 'valid-token' }, state: { accessToken: 'valid-token', refreshToken: 'valid-refresh-token', @@ -432,6 +442,7 @@ describe('SAMLAuthenticationProvider', () => { }) ).resolves.toEqual( AuthenticationResult.redirectTo(`${mockOptions.basePath.serverBasePath}/`, { + userProfileGrant: { type: 'accessToken', accessToken: 'valid-token' }, state: { accessToken: 'valid-token', refreshToken: 'valid-refresh-token', @@ -601,6 +612,7 @@ describe('SAMLAuthenticationProvider', () => { ) ).resolves.toEqual( AuthenticationResult.redirectTo('/mock-server-basepath/', { + userProfileGrant: { type: 'accessToken', accessToken: 'new-valid-token' }, state: { accessToken: 'new-valid-token', refreshToken: 'new-valid-refresh-token', @@ -663,6 +675,7 @@ describe('SAMLAuthenticationProvider', () => { ) ).resolves.toEqual( AuthenticationResult.redirectTo('/mock-server-basepath/app/some-app#some-deep-link', { + userProfileGrant: { type: 'accessToken', accessToken: 'new-valid-token' }, state: { accessToken: 'new-valid-token', refreshToken: 'new-valid-refresh-token', diff --git a/x-pack/plugins/security/server/authentication/providers/saml.ts b/x-pack/plugins/security/server/authentication/providers/saml.ts index fc9704747cf95..1d162d9d228b7 100644 --- a/x-pack/plugins/security/server/authentication/providers/saml.ts +++ b/x-pack/plugins/security/server/authentication/providers/saml.ts @@ -409,12 +409,13 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { return AuthenticationResult.redirectTo( redirectURLFromRelayState || stateRedirectURL || `${this.options.basePath.get(request)}/`, { + user: this.authenticationInfoToAuthenticatedUser(result.authentication), + userProfileGrant: { type: 'accessToken', accessToken: result.access_token }, state: { accessToken: result.access_token, refreshToken: result.refresh_token, realm: result.realm, }, - user: this.authenticationInfoToAuthenticatedUser(result.authentication), } ); } diff --git a/x-pack/plugins/security/server/authentication/providers/token.test.ts b/x-pack/plugins/security/server/authentication/providers/token.test.ts index 989fc717d003d..a9e5d6691f4ea 100644 --- a/x-pack/plugins/security/server/authentication/providers/token.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/token.test.ts @@ -61,7 +61,11 @@ describe('TokenAuthenticationProvider', () => { await expect(provider.login(request, credentials)).resolves.toEqual( AuthenticationResult.succeeded( { ...user, authentication_provider: { type: 'token', name: 'token' } }, - { authHeaders: { authorization }, state: tokenPair } + { + authHeaders: { authorization }, + userProfileGrant: { type: 'accessToken', accessToken: tokenPair.accessToken }, + state: tokenPair, + } ) ); diff --git a/x-pack/plugins/security/server/authentication/providers/token.ts b/x-pack/plugins/security/server/authentication/providers/token.ts index c66ab7e80332d..23af39167b00c 100644 --- a/x-pack/plugins/security/server/authentication/providers/token.ts +++ b/x-pack/plugins/security/server/authentication/providers/token.ts @@ -86,6 +86,7 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { authenticationInfo as AuthenticationInfo ), { + userProfileGrant: { type: 'accessToken', accessToken }, authHeaders: { authorization: new HTTPAuthorizationHeader('Bearer', accessToken).toString(), }, diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index b710d200259be..270d54e82df6a 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -187,13 +187,13 @@ export class SecurityPlugin return this.anonymousAccessStart; }; - private readonly profileService: UserProfileService; - private profileStart?: UserProfileServiceStart; - private readonly getProfileService = () => { - if (!this.profileStart) { - throw new Error(`profileStart is not registered!`); + private readonly userProfileService: UserProfileService; + private userProfileStart?: UserProfileServiceStart; + private readonly getUserProfileService = () => { + if (!this.userProfileStart) { + throw new Error(`userProfileStart is not registered!`); } - return this.profileStart; + return this.userProfileStart; }; constructor(private readonly initializerContext: PluginInitializerContext) { @@ -213,7 +213,9 @@ export class SecurityPlugin this.initializerContext.logger.get('anonymous-access'), this.getConfig ); - this.profileService = new UserProfileService(this.initializerContext.logger.get('profile')); + this.userProfileService = new UserProfileService( + this.initializerContext.logger.get('user-profile') + ); } public setup( @@ -320,7 +322,7 @@ export class SecurityPlugin getFeatureUsageService: this.getFeatureUsageService, getAuthenticationService: this.getAuthentication, getAnonymousAccessService: this.getAnonymousAccess, - getUserProfileService: this.getProfileService, + getUserProfileService: this.getUserProfileService, }); return Object.freeze({ @@ -367,12 +369,15 @@ export class SecurityPlugin }); this.session = session; + this.userProfileStart = this.userProfileService.start({ clusterClient }); + const config = this.getConfig(); this.authenticationStart = this.authenticationService.start({ audit: this.auditSetup!, clusterClient, config, featureUsageService: this.featureUsageServiceStart, + userProfileService: this.userProfileStart, http: core.http, loggers: this.initializerContext.logger, session, @@ -387,8 +392,6 @@ export class SecurityPlugin spaces: spaces?.spacesService, }); - this.profileStart = this.profileService.start(clusterClient.asInternalUser); - return Object.freeze({ authc: { apiKeys: this.authenticationStart.apiKeys, diff --git a/x-pack/plugins/security/server/routes/index.ts b/x-pack/plugins/security/server/routes/index.ts index 12703a535eb84..dbfeb03a4b6c3 100644 --- a/x-pack/plugins/security/server/routes/index.ts +++ b/x-pack/plugins/security/server/routes/index.ts @@ -19,17 +19,17 @@ import type { ConfigType } from '../config'; import type { SecurityFeatureUsageServiceStart } from '../feature_usage'; import type { Session } from '../session_management'; import type { SecurityRouter } from '../types'; -import type { UserProfileServiceStart } from '../user_profile/user_profile_service'; +import type { UserProfileServiceStart } from '../user_profile'; import { defineAnonymousAccessRoutes } from './anonymous_access'; import { defineApiKeysRoutes } from './api_keys'; import { defineAuthenticationRoutes } from './authentication'; import { defineAuthorizationRoutes } from './authorization'; import { defineDeprecationsRoutes } from './deprecations'; import { defineIndicesRoutes } from './indices'; -import { defineProfileRoutes } from './profile'; import { defineRoleMappingRoutes } from './role_mapping'; import { defineSecurityCheckupGetStateRoutes } from './security_checkup'; import { defineSessionManagementRoutes } from './session_management'; +import { defineUserProfileRoutes } from './user_profile'; import { defineUsersRoutes } from './users'; import { defineViewRoutes } from './views'; @@ -60,7 +60,7 @@ export function defineRoutes(params: RouteDefinitionParams) { defineApiKeysRoutes(params); defineIndicesRoutes(params); defineUsersRoutes(params); - defineProfileRoutes(params); + defineUserProfileRoutes(params); defineRoleMappingRoutes(params); defineViewRoutes(params); defineDeprecationsRoutes(params); diff --git a/x-pack/plugins/security/server/routes/profile/get.ts b/x-pack/plugins/security/server/routes/user_profile/get.ts similarity index 80% rename from x-pack/plugins/security/server/routes/profile/get.ts rename to x-pack/plugins/security/server/routes/user_profile/get.ts index f3e01ba6fb424..9bfa2fe16a682 100644 --- a/x-pack/plugins/security/server/routes/profile/get.ts +++ b/x-pack/plugins/security/server/routes/user_profile/get.ts @@ -11,13 +11,14 @@ import type { RouteDefinitionParams } from '../'; import { wrapIntoCustomErrorResponse } from '../../errors'; import { createLicensedRouteHandler } from '../licensed_route_handler'; -export function defineGetProfileRoute({ router, getUserProfileService }: RouteDefinitionParams) { +export function defineGetUserProfileRoute({ + router, + getUserProfileService, +}: RouteDefinitionParams) { router.get( { - path: '/internal/security/profile/{uid}', - options: { - tags: ['access:accessUserProfile', 'access:updateUserProfle'], - }, + path: '/internal/security/user_profile/{uid}', + options: { tags: ['access:accessUserProfile'] }, validate: { params: schema.object({ uid: schema.string() }), }, diff --git a/x-pack/plugins/security/server/routes/profile/index.ts b/x-pack/plugins/security/server/routes/user_profile/index.ts similarity index 53% rename from x-pack/plugins/security/server/routes/profile/index.ts rename to x-pack/plugins/security/server/routes/user_profile/index.ts index ae42ec8c3aee1..293ec0e75cf30 100644 --- a/x-pack/plugins/security/server/routes/profile/index.ts +++ b/x-pack/plugins/security/server/routes/user_profile/index.ts @@ -6,10 +6,10 @@ */ import type { RouteDefinitionParams } from '../index'; -import { defineGetProfileRoute } from './get'; -import { defineUpdateProfileDataRoute } from './update'; +import { defineGetUserProfileRoute } from './get'; +import { defineUpdateUserProfileDataRoute } from './update'; -export function defineProfileRoutes(params: RouteDefinitionParams) { - defineUpdateProfileDataRoute(params); - defineGetProfileRoute(params); +export function defineUserProfileRoutes(params: RouteDefinitionParams) { + defineUpdateUserProfileDataRoute(params); + defineGetUserProfileRoute(params); } diff --git a/x-pack/plugins/security/server/routes/profile/update.ts b/x-pack/plugins/security/server/routes/user_profile/update.ts similarity index 84% rename from x-pack/plugins/security/server/routes/profile/update.ts rename to x-pack/plugins/security/server/routes/user_profile/update.ts index 719e2b8036113..00cbfea3a957b 100644 --- a/x-pack/plugins/security/server/routes/profile/update.ts +++ b/x-pack/plugins/security/server/routes/user_profile/update.ts @@ -11,16 +11,14 @@ import type { RouteDefinitionParams } from '../'; import { wrapIntoCustomErrorResponse } from '../../errors'; import { createLicensedRouteHandler } from '../licensed_route_handler'; -export function defineUpdateProfileDataRoute({ +export function defineUpdateUserProfileDataRoute({ router, getUserProfileService, }: RouteDefinitionParams) { router.post( { - path: '/internal/security/profile/_data/{uid}', - options: { - tags: ['access:accessUserProfile', 'access:updateUserProfle'], - }, + path: '/internal/security/user_profile/_data/{uid}', + options: { tags: ['access:updateUserProfile'] }, validate: { params: schema.object({ uid: schema.string() }), body: schema.recordOf(schema.string(), schema.any()), diff --git a/x-pack/plugins/security/server/session_management/session.mock.ts b/x-pack/plugins/security/server/session_management/session.mock.ts index 65ae43e5fa705..3941e38a98ca1 100644 --- a/x-pack/plugins/security/server/session_management/session.mock.ts +++ b/x-pack/plugins/security/server/session_management/session.mock.ts @@ -24,6 +24,7 @@ export const sessionMock = { createValue: (sessionValue: Partial = {}): SessionValue => ({ sid: 'some-long-sid', username: mockAuthenticatedUser().username, + userProfileId: 'uid', provider: { type: 'basic', name: 'basic1' }, idleTimeoutExpiration: null, lifespanExpiration: null, diff --git a/x-pack/plugins/security/server/session_management/session.test.ts b/x-pack/plugins/security/server/session_management/session.test.ts index e1c67dca667f7..e96caa27ad29b 100644 --- a/x-pack/plugins/security/server/session_management/session.test.ts +++ b/x-pack/plugins/security/server/session_management/session.test.ts @@ -80,7 +80,10 @@ describe('Session', () => { mockSessionCookie.get.mockResolvedValue(null); mockSessionIndex.get.mockResolvedValue( sessionIndexMock.createValue({ - content: await encryptContent({ username: 'some-user', state: 'some-state' }, mockAAD), + content: await encryptContent( + { username: 'some-user', state: 'some-state', userProfileId: 'uid' }, + mockAAD + ), }) ); @@ -97,7 +100,10 @@ describe('Session', () => { ); mockSessionIndex.get.mockResolvedValue( sessionIndexMock.createValue({ - content: await encryptContent({ username: 'some-user', state: 'some-state' }, mockAAD), + content: await encryptContent( + { username: 'some-user', state: 'some-state', userProfileId: 'uid' }, + mockAAD + ), }) ); @@ -116,7 +122,10 @@ describe('Session', () => { ); mockSessionIndex.get.mockResolvedValue( sessionIndexMock.createValue({ - content: await encryptContent({ username: 'some-user', state: 'some-state' }, mockAAD), + content: await encryptContent( + { username: 'some-user', state: 'some-state', userProfileId: 'uid' }, + mockAAD + ), }) ); @@ -164,7 +173,10 @@ describe('Session', () => { ); mockSessionIndex.get.mockResolvedValue( sessionIndexMock.createValue({ - content: await encryptContent({ username: 'some-user', state: 'some-state' }, mockAAD), + content: await encryptContent( + { username: 'some-user', state: 'some-state', userProfileId: 'uid' }, + mockAAD + ), }) ); @@ -185,7 +197,10 @@ describe('Session', () => { const mockSessionIndexValue = sessionIndexMock.createValue({ idleTimeoutExpiration: now - 1, lifespanExpiration: now + 1, - content: await encryptContent({ username: 'some-user', state: 'some-state' }, mockAAD), + content: await encryptContent( + { username: 'some-user', state: 'some-state', userProfileId: 'uid' }, + mockAAD + ), }); mockSessionIndex.get.mockResolvedValue(mockSessionIndexValue); @@ -197,6 +212,35 @@ describe('Session', () => { sid: 'some-long-sid', state: 'some-state', username: 'some-user', + userProfileId: 'uid', + }); + expect(mockSessionCookie.clear).not.toHaveBeenCalled(); + expect(mockSessionIndex.invalidate).not.toHaveBeenCalled(); + }); + + it('returns session value with decrypted content if optional fields are missing', async () => { + mockSessionCookie.get.mockResolvedValue( + sessionCookieMock.createValue({ + aad: mockAAD, + idleTimeoutExpiration: now + 1, + lifespanExpiration: now + 1, + }) + ); + + const mockSessionIndexValue = sessionIndexMock.createValue({ + idleTimeoutExpiration: now - 1, + lifespanExpiration: now + 1, + content: await encryptContent({ state: 'some-state' }, mockAAD), + }); + mockSessionIndex.get.mockResolvedValue(mockSessionIndexValue); + + await expect(session.get(httpServerMock.createKibanaRequest())).resolves.toEqual({ + idleTimeoutExpiration: now + 1, + lifespanExpiration: now + 1, + metadata: { index: mockSessionIndexValue }, + provider: { name: 'basic1', type: 'basic' }, + sid: 'some-long-sid', + state: 'some-state', }); expect(mockSessionCookie.clear).not.toHaveBeenCalled(); expect(mockSessionIndex.invalidate).not.toHaveBeenCalled(); @@ -219,12 +263,14 @@ describe('Session', () => { await expect( session.create(mockRequest, { username: mockAuthenticatedUser().username, + userProfileId: 'uid', provider: { type: 'basic', name: 'basic1' }, state: 'some-state', }) ).resolves.toEqual({ sid: mockSID, username: 'user', + userProfileId: 'uid', state: 'some-state', provider: { name: 'basic1', type: 'basic' }, idleTimeoutExpiration: now + 123, @@ -237,7 +283,7 @@ describe('Session', () => { expect(mockSessionIndex.create).toHaveBeenCalledWith({ sid: mockSID, content: - 'AwABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9PgQAAQIDBAUGBwgJCpgMitlj6jACf9fYYa66WkuUpJsdgbWevIEfo6mN827f0lGcKDNPzN+vDMMPFetOkRITDI+NMz7e3JcMofnDboRnvg==', + 'AwABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9PgQAAQIDBAUGBwgJChvXfqiKj6k6TtyXr4HA5s2UpJsdgbWevIEfo6mN827f0lGcKDNPzN+vDMUIEe17v19POcGbFTfn094k4vjPVvo4sKPZMbNZmsQnCuqRNKIx4DZwVTlsKNizEUKP', provider: { name: 'basic1', type: 'basic' }, usernameHash: '8ac76453d769d4fd14b3f41ad4933f9bd64321972cd002de9b847e117435b08b', idleTimeoutExpiration: now + 123, @@ -253,6 +299,53 @@ describe('Session', () => { lifespanExpiration: now + 456, }); }); + + it('creates session value if optional fields are missing', async () => { + const mockSID = Buffer.from([1, ...Array(31).keys()]).toString('base64'); + const mockAAD = Buffer.from([2, ...Array(31).keys()]).toString('base64'); + + const mockSessionIndexValue = sessionIndexMock.createValue({ + sid: mockSID, + idleTimeoutExpiration: now + 123, + lifespanExpiration: now + 456, + }); + mockSessionIndex.create.mockResolvedValue(mockSessionIndexValue); + + const mockRequest = httpServerMock.createKibanaRequest(); + await expect( + session.create(mockRequest, { + provider: { type: 'basic', name: 'basic1' }, + state: 'some-state', + }) + ).resolves.toEqual({ + sid: mockSID, + state: 'some-state', + provider: { name: 'basic1', type: 'basic' }, + idleTimeoutExpiration: now + 123, + lifespanExpiration: now + 456, + metadata: { index: mockSessionIndexValue }, + }); + + // Properly creates session index value. + expect(mockSessionIndex.create).toHaveBeenCalledTimes(1); + expect(mockSessionIndex.create).toHaveBeenCalledWith({ + sid: mockSID, + content: + 'AwABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9PgQAAQIDBAUGBwgJCnCZRj98P6cRfxXeABhK52uUpJsdh7Kauooi7PaN8yfsnUHCPjVympavDM1Z', + provider: { name: 'basic1', type: 'basic' }, + idleTimeoutExpiration: now + 123, + lifespanExpiration: now + 456, + }); + + // Properly creates session cookie value. + expect(mockSessionCookie.set).toHaveBeenCalledTimes(1); + expect(mockSessionCookie.set).toHaveBeenCalledWith(mockRequest, { + sid: mockSID, + aad: mockAAD, + idleTimeoutExpiration: now + 123, + lifespanExpiration: now + 456, + }); + }); }); describe('#update', () => { @@ -263,7 +356,10 @@ describe('Session', () => { // To make sure we aren't even calling this method. mockSessionIndex.update.mockResolvedValue( sessionIndexMock.createValue({ - content: await encryptContent({ username: 'some-user', state: 'some-state' }, mockAAD), + content: await encryptContent( + { username: 'some-user', state: 'some-state', userProfileId: 'uid' }, + mockAAD + ), }) ); @@ -306,6 +402,7 @@ describe('Session', () => { mockRequest, sessionMock.createValue({ username: 'new-user', + userProfileId: 'new-uid', state: 'new-state', idleTimeoutExpiration: now + 1, lifespanExpiration: now + 1, @@ -314,6 +411,7 @@ describe('Session', () => { ).resolves.toEqual({ sid: 'some-long-sid', username: 'new-user', + userProfileId: 'new-uid', state: 'new-state', provider: { name: 'basic1', type: 'basic' }, idleTimeoutExpiration: now + 123, @@ -326,7 +424,7 @@ describe('Session', () => { expect(mockSessionIndex.update).toHaveBeenCalledWith({ sid: 'some-long-sid', content: - 'AQABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9PgIAAQIDBAUGBwgJCt8yPPMsaNAxn7qtLtc57UN967e9FpjmJgEIipe6nD20F47TtNIZnAuzd75zc8TNWvPMgRTzpHnYz7cT9m5ouv2V8TZ+ow==', + 'AQABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9PgIAAQIDBAUGBwgJClC2bGDkxgH3vSXI2+oux3R967e9FpjmJgEIipe6nD20F47TtNIZnAuzd75zc8TLXffKtDq++EyWxJsAhz8mh6ueyGMu9LSLuawgiBy7y2ULLjoCPZwslb1GPrBe+9+BiE9Jow==', provider: { name: 'basic1', type: 'basic' }, usernameHash: '35133597af273830c3f139c72501e676338f28a39dca8ff62d5c2b8bfba75f69', idleTimeoutExpiration: now + 123, @@ -345,6 +443,64 @@ describe('Session', () => { }); }); + it('updates session value if optional fields are missing', async () => { + mockSessionCookie.get.mockResolvedValue( + sessionCookieMock.createValue({ + aad: mockAAD, + idleTimeoutExpiration: now + 1, + lifespanExpiration: now + 1, + }) + ); + + const mockSessionIndexValue = sessionIndexMock.createValue({ + idleTimeoutExpiration: now + 123, + lifespanExpiration: now + 1, + metadata: { primaryTerm: 2, sequenceNumber: 2 }, + }); + mockSessionIndex.update.mockResolvedValue(mockSessionIndexValue); + + const mockRequest = httpServerMock.createKibanaRequest(); + await expect( + session.update( + mockRequest, + sessionMock.createValue({ + username: undefined, + userProfileId: undefined, + idleTimeoutExpiration: now + 1, + lifespanExpiration: now + 1, + }) + ) + ).resolves.toEqual({ + sid: 'some-long-sid', + provider: { name: 'basic1', type: 'basic' }, + idleTimeoutExpiration: now + 123, + lifespanExpiration: now + 1, + metadata: { index: mockSessionIndexValue }, + }); + + // Properly updates session index value. + expect(mockSessionIndex.update).toHaveBeenCalledTimes(1); + expect(mockSessionIndex.update).toHaveBeenCalledWith({ + sid: 'some-long-sid', + content: + 'AQABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9PgIAAQIDBAUGBwgJCvi9HQSbzhgXyhSge7K3rrF965a9', + provider: { name: 'basic1', type: 'basic' }, + idleTimeoutExpiration: now + 123, + lifespanExpiration: now + 1, + metadata: { primaryTerm: 1, sequenceNumber: 1 }, + }); + + // Properly updates session cookie value. + expect(mockSessionCookie.set).toHaveBeenCalledTimes(1); + expect(mockSessionCookie.set).toHaveBeenCalledWith(mockRequest, { + sid: 'some-long-sid', + aad: mockAAD, + path: '/mock-base-path', + idleTimeoutExpiration: now + 123, + lifespanExpiration: now + 1, + }); + }); + it('properly extends session expiration if idle timeout is defined.', async () => { mockSessionCookie.get.mockResolvedValue( sessionCookieMock.createValue({ aad: mockAAD, idleTimeoutExpiration: now + 1 }) diff --git a/x-pack/plugins/security/server/session_management/session.ts b/x-pack/plugins/security/server/session_management/session.ts index 8d2b56b4d2b7a..c4ef19a7e7938 100644 --- a/x-pack/plugins/security/server/session_management/session.ts +++ b/x-pack/plugins/security/server/session_management/session.ts @@ -13,7 +13,7 @@ import { promisify } from 'util'; import type { PublicMethodsOf } from '@kbn/utility-types'; import type { KibanaRequest, Logger } from 'src/core/server'; -import type { AuthenticationProvider } from '../../common/model'; +import type { AuthenticationProvider } from '../../common'; import type { ConfigType } from '../config'; import type { SessionCookie } from './session_cookie'; import type { SessionIndex, SessionIndexValue } from './session_index'; @@ -56,6 +56,12 @@ export interface SessionValue { */ state: unknown; + /** + * Unique identifier of the user profile, if any. Not all users that have session will have an associated user + * profile, e.g. anonymous users won't have it. + */ + userProfileId?: string; + /** * Indicates whether user acknowledged access agreement or not. */ @@ -76,6 +82,7 @@ export interface SessionOptions { export interface SessionValueContentToEncrypt { username?: string; + userProfileId?: string; state: unknown; } @@ -173,7 +180,7 @@ export class Session { return { ...Session.sessionIndexValueToSessionValue(sessionIndexValue, decryptedContent), - // Unlike session index, session cookie contains the most up to date idle timeout expiration. + // Unlike session index, session cookie contains the most up-to-date idle timeout expiration. idleTimeoutExpiration: sessionCookieValue.idleTimeoutExpiration, }; } @@ -198,7 +205,7 @@ export class Session { sessionLogger.debug('Creating a new session.'); const sessionExpirationInfo = this.calculateExpiry(sessionValue.provider); - const { username, state, ...publicSessionValue } = sessionValue; + const { username, userProfileId, state, ...publicSessionValue } = sessionValue; // First try to store session in the index and only then in the cookie to make sure cookie is // only updated if server side session is created successfully. @@ -207,14 +214,18 @@ export class Session { ...sessionExpirationInfo, sid, usernameHash: username && Session.getUsernameHash(username), - content: await this.crypto.encrypt(JSON.stringify({ username, state }), aad), + content: await this.crypto.encrypt(JSON.stringify({ username, userProfileId, state }), aad), }); await this.options.sessionCookie.set(request, { ...sessionExpirationInfo, sid, aad }); sessionLogger.debug('Successfully created a new session.'); - return Session.sessionIndexValueToSessionValue(sessionIndexValue, { username, state }); + return Session.sessionIndexValueToSessionValue(sessionIndexValue, { + username, + userProfileId, + state, + }); } /** @@ -234,7 +245,7 @@ export class Session { sessionValue.provider, sessionCookieValue.lifespanExpiration ); - const { username, state, metadata, ...publicSessionInfo } = sessionValue; + const { username, userProfileId, state, metadata, ...publicSessionInfo } = sessionValue; // First try to store session in the index and only then in the cookie to make sure cookie is // only updated if server side session is created successfully. @@ -244,7 +255,7 @@ export class Session { ...sessionExpirationInfo, usernameHash: username && Session.getUsernameHash(username), content: await this.crypto.encrypt( - JSON.stringify({ username, state }), + JSON.stringify({ username, userProfileId, state }), sessionCookieValue.aad ), }); @@ -264,7 +275,11 @@ export class Session { sessionLogger.debug('Successfully updated existing session.'); - return Session.sessionIndexValueToSessionValue(sessionIndexValue, { username, state }); + return Session.sessionIndexValueToSessionValue(sessionIndexValue, { + username, + userProfileId, + state, + }); } /** @@ -446,11 +461,17 @@ export class Session { */ private static sessionIndexValueToSessionValue( sessionIndexValue: Readonly, - { username, state }: SessionValueContentToEncrypt + { username, userProfileId, state }: SessionValueContentToEncrypt ): Readonly { // Extract values that are specific to session index value. const { usernameHash, content, ...publicSessionValue } = sessionIndexValue; - return { ...publicSessionValue, username, state, metadata: { index: sessionIndexValue } }; + return { + ...publicSessionValue, + username, + userProfileId, + state, + metadata: { index: sessionIndexValue }, + }; } /** diff --git a/x-pack/plugins/security/server/user_profile/index.ts b/x-pack/plugins/security/server/user_profile/index.ts index 2e46a3ac571c3..b9650bd5fb2ee 100644 --- a/x-pack/plugins/security/server/user_profile/index.ts +++ b/x-pack/plugins/security/server/user_profile/index.ts @@ -5,9 +5,10 @@ * 2.0. */ -export { - UserData, - UserProfile, - UserProfileService, +export { UserProfileService } from './user_profile_service'; +export type { UserProfileServiceStart, + UserProfileServiceStartParams, } from './user_profile_service'; +export type { UserProfile, UserData } from './user_profile'; +export type { UserProfileGrant } from './user_profile_grant'; diff --git a/x-pack/plugins/security/server/user_profile/user_profile.mock.ts b/x-pack/plugins/security/server/user_profile/user_profile.mock.ts new file mode 100644 index 0000000000000..63573b62215f5 --- /dev/null +++ b/x-pack/plugins/security/server/user_profile/user_profile.mock.ts @@ -0,0 +1,18 @@ +/* + * 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 type { UserProfile } from './user_profile'; + +export const userProfileMock = { + create: (userProfile: Partial = {}): UserProfile => ({ + uid: 'some-profile-uid', + enabled: true, + user: { username: 'some-username', active: true, roles: [], enabled: true }, + data: {}, + ...userProfile, + }), +}; diff --git a/x-pack/plugins/security/server/user_profile/user_profile.ts b/x-pack/plugins/security/server/user_profile/user_profile.ts new file mode 100644 index 0000000000000..0525cacc10614 --- /dev/null +++ b/x-pack/plugins/security/server/user_profile/user_profile.ts @@ -0,0 +1,45 @@ +/* + * 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 type { User } from '../../common'; + +export interface UserInfo extends User { + display_name?: string; + avatar?: { + initials?: string; + color?: string; + image_url?: string; + }; + active: boolean; +} + +export type UserData = Record; + +/** + * Describes properties of the user's profile. + */ +export interface UserProfile> { + /** + * Unique ID for of the user profile. + */ + uid: string; + + /** + * Indicates whether user profile is enabled or not. + */ + enabled: boolean; + + /** + * Information about the user that owns profile. + */ + user: UserInfo; + + /** + * User specific data associated with the profile. + */ + data: T; +} diff --git a/x-pack/plugins/security/server/user_profile/user_profile_grant.ts b/x-pack/plugins/security/server/user_profile/user_profile_grant.ts new file mode 100644 index 0000000000000..b12a46b00174b --- /dev/null +++ b/x-pack/plugins/security/server/user_profile/user_profile_grant.ts @@ -0,0 +1,28 @@ +/* + * 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. + */ + +/** + * Represents a union of all possible user profile grant types. + */ +export type UserProfileGrant = PasswordUserProfileGrant | AccessTokenUserProfileGrant; + +/** + * The user profile grant represented by the username and password. + */ +export interface PasswordUserProfileGrant { + readonly type: 'password'; + readonly username: string; + readonly password: string; +} + +/** + * The user profile grant represented by the access token. + */ +export interface AccessTokenUserProfileGrant { + readonly type: 'accessToken'; + readonly accessToken: string; +} diff --git a/x-pack/plugins/security/server/user_profile/user_profile_service.mock.ts b/x-pack/plugins/security/server/user_profile/user_profile_service.mock.ts new file mode 100644 index 0000000000000..fa7623d40d362 --- /dev/null +++ b/x-pack/plugins/security/server/user_profile/user_profile_service.mock.ts @@ -0,0 +1,17 @@ +/* + * 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 type { UserProfileServiceStart } from '../user_profile'; +import { userProfileMock } from './user_profile.mock'; + +export const userProfileServiceMock = { + createStart: (): jest.Mocked => ({ + activate: jest.fn().mockReturnValue(userProfileMock.create()), + get: jest.fn(), + update: jest.fn(), + }), +}; diff --git a/x-pack/plugins/security/server/user_profile/user_profile_service.test.ts b/x-pack/plugins/security/server/user_profile/user_profile_service.test.ts index 8263392daf2b6..e05d72e69b93f 100644 --- a/x-pack/plugins/security/server/user_profile/user_profile_service.test.ts +++ b/x-pack/plugins/security/server/user_profile/user_profile_service.test.ts @@ -5,116 +5,232 @@ * 2.0. */ +import { errors } from '@elastic/elasticsearch'; + import { elasticsearchServiceMock, loggingSystemMock } from 'src/core/server/mocks'; +import { securityMock } from '../mocks'; +import { userProfileMock } from './user_profile.mock'; import { UserProfileService } from './user_profile_service'; const logger = loggingSystemMock.createLogger(); const userProfileService = new UserProfileService(logger); -const elasticsearchClient = elasticsearchServiceMock.createElasticsearchClient(); describe('UserProfileService', () => { + let mockStartParams: { + clusterClient: ReturnType; + }; + beforeEach(() => { - elasticsearchClient.transport.request.mockReturnValue( - elasticsearchServiceMock.createSuccessTransportRequestPromise({ - UID: { - uid: 'UID', - user: {}, - data: { - kibana: { - avatar: 'fun.gif', - }, - other_app: { - secret: 'data', - }, - }, + mockStartParams = { + clusterClient: elasticsearchServiceMock.createClusterClient(), + }; + + const userProfile = userProfileMock.create({ + uid: 'UID', + data: { + kibana: { + avatar: 'fun.gif', }, - }) - ); + other_app: { + secret: 'data', + }, + }, + }); + mockStartParams.clusterClient.asInternalUser.transport.request.mockResolvedValue({ + [userProfile.uid]: userProfile, + }); }); afterEach(() => { - elasticsearchClient.transport.request.mockClear(); logger.error.mockClear(); }); it('should expose correct start contract', () => { - const startContract = userProfileService.start(elasticsearchClient); + const startContract = userProfileService.start(mockStartParams); expect(startContract).toMatchInlineSnapshot(` Object { + "activate": [Function], "get": [Function], "update": [Function], } `); }); - it('should get user profile', async () => { - const startContract = userProfileService.start(elasticsearchClient); - await expect(startContract.get('UID')).resolves.toMatchInlineSnapshot(` - Object { - "data": Object { - "avatar": "fun.gif", - }, - "uid": "UID", - "user": Object {}, - } - `); - expect(elasticsearchClient.transport.request).toHaveBeenCalledWith({ - method: 'GET', - path: '_security/profile/UID', + describe('#get', () => { + it('should get user profile', async () => { + const startContract = userProfileService.start(mockStartParams); + await expect(startContract.get('UID')).resolves.toMatchInlineSnapshot(` + Object { + "data": Object { + "avatar": "fun.gif", + }, + "enabled": true, + "uid": "UID", + "user": Object { + "active": true, + "enabled": true, + "roles": Array [], + "username": "some-username", + }, + } + `); + expect(mockStartParams.clusterClient.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'GET', + path: '_security/profile/UID', + }); }); - }); - it('should handle errors when get user profile fails', async () => { - elasticsearchClient.transport.request.mockRejectedValue(new Error('Fail')); - const startContract = userProfileService.start(elasticsearchClient); - await expect(startContract.get('UID')).rejects.toMatchInlineSnapshot(`[Error: Fail]`); - expect(logger.error).toHaveBeenCalled(); - }); + it('should handle errors when get user profile fails', async () => { + mockStartParams.clusterClient.asInternalUser.transport.request.mockRejectedValue( + new Error('Fail') + ); + const startContract = userProfileService.start(mockStartParams); + await expect(startContract.get('UID')).rejects.toMatchInlineSnapshot(`[Error: Fail]`); + expect(logger.error).toHaveBeenCalled(); + }); - it('should get user profile and application data scoped to Kibana', async () => { - const startContract = userProfileService.start(elasticsearchClient); - await expect(startContract.get('UID', '*')).resolves.toMatchInlineSnapshot(` - Object { - "data": Object { - "avatar": "fun.gif", - }, - "uid": "UID", - "user": Object {}, - } - `); - expect(elasticsearchClient.transport.request).toHaveBeenCalledWith({ - method: 'GET', - path: '_security/profile/UID?data=kibana.*', + it('should get user profile and application data scoped to Kibana', async () => { + const startContract = userProfileService.start(mockStartParams); + await expect(startContract.get('UID', '*')).resolves.toMatchInlineSnapshot(` + Object { + "data": Object { + "avatar": "fun.gif", + }, + "enabled": true, + "uid": "UID", + "user": Object { + "active": true, + "enabled": true, + "roles": Array [], + "username": "some-username", + }, + } + `); + expect(mockStartParams.clusterClient.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'GET', + path: '_security/profile/UID?data=kibana.*', + }); }); }); - it('should update application data scoped to Kibana', async () => { - const startContract = userProfileService.start(elasticsearchClient); - await startContract.update('UID', { - avatar: 'boring.png', - }); - expect(elasticsearchClient.transport.request).toHaveBeenCalledWith({ - body: { - data: { - kibana: { - avatar: 'boring.png', + describe('#update', () => { + it('should update application data scoped to Kibana', async () => { + const startContract = userProfileService.start(mockStartParams); + await startContract.update('UID', { + avatar: 'boring.png', + }); + expect(mockStartParams.clusterClient.asInternalUser.transport.request).toHaveBeenCalledWith({ + body: { + data: { + kibana: { + avatar: 'boring.png', + }, }, }, - }, - method: 'POST', - path: '_security/profile/_data/UID', + method: 'POST', + path: '_security/profile/_data/UID', + }); + }); + + it('should handle errors when update user profile fails', async () => { + mockStartParams.clusterClient.asInternalUser.transport.request.mockRejectedValue( + new Error('Fail') + ); + const startContract = userProfileService.start(mockStartParams); + await expect( + startContract.update('UID', { + avatar: 'boring.png', + }) + ).rejects.toMatchInlineSnapshot(`[Error: Fail]`); + expect(logger.error).toHaveBeenCalled(); }); }); - it('should handle errors when update user profile fails', async () => { - elasticsearchClient.transport.request.mockRejectedValue(new Error('Fail')); - const startContract = userProfileService.start(elasticsearchClient); - await expect( - startContract.update('UID', { - avatar: 'boring.png', - }) - ).rejects.toMatchInlineSnapshot(`[Error: Fail]`); - expect(logger.error).toHaveBeenCalled(); + describe('#activate', () => { + beforeEach(() => { + mockStartParams.clusterClient.asInternalUser.transport.request.mockResolvedValue( + userProfileMock.create() + ); + }); + + it('should activate user profile with password grant', async () => { + const startContract = userProfileService.start(mockStartParams); + await expect( + startContract.activate({ + type: 'password', + username: 'some-username', + password: 'password', + }) + ).resolves.toMatchInlineSnapshot(` + Object { + "data": Object {}, + "enabled": true, + "uid": "some-profile-uid", + "user": Object { + "active": true, + "enabled": true, + "roles": Array [], + "username": "some-username", + }, + } + `); + expect(mockStartParams.clusterClient.asInternalUser.transport.request).toHaveBeenCalledTimes( + 1 + ); + expect(mockStartParams.clusterClient.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '_security/profile/_activate', + body: { grant_type: 'password', password: 'password', username: 'some-username' }, + }); + }); + + it('should activate user profile with access token grant', async () => { + const startContract = userProfileService.start(mockStartParams); + await expect(startContract.activate({ type: 'accessToken', accessToken: 'some-token' })) + .resolves.toMatchInlineSnapshot(` + Object { + "data": Object {}, + "enabled": true, + "uid": "some-profile-uid", + "user": Object { + "active": true, + "enabled": true, + "roles": Array [], + "username": "some-username", + }, + } + `); + expect(mockStartParams.clusterClient.asInternalUser.transport.request).toHaveBeenCalledTimes( + 1 + ); + expect(mockStartParams.clusterClient.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '_security/profile/_activate', + body: { grant_type: 'access_token', access_token: 'some-token' }, + }); + }); + + it('fails if activation fails', async () => { + const failureReason = new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 409, body: 'some message' }) + ); + mockStartParams.clusterClient.asInternalUser.transport.request.mockRejectedValue( + failureReason + ); + + const startContract = userProfileService.start(mockStartParams); + await expect( + startContract.activate({ type: 'accessToken', accessToken: 'some-token' }) + ).rejects.toBe(failureReason); + expect(mockStartParams.clusterClient.asInternalUser.transport.request).toHaveBeenCalledTimes( + 1 + ); + expect(mockStartParams.clusterClient.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '_security/profile/_activate', + body: { grant_type: 'access_token', access_token: 'some-token' }, + }); + }); }); }); diff --git a/x-pack/plugins/security/server/user_profile/user_profile_service.ts b/x-pack/plugins/security/server/user_profile/user_profile_service.ts index ba12844744d3a..dbdc6d17e1581 100644 --- a/x-pack/plugins/security/server/user_profile/user_profile_service.ts +++ b/x-pack/plugins/security/server/user_profile/user_profile_service.ts @@ -5,31 +5,21 @@ * 2.0. */ -import type { ElasticsearchClient, Logger } from 'src/core/server'; +import type { IClusterClient, Logger } from 'src/core/server'; -import type { User } from '../../common'; +import { getDetailedErrorMessage } from '../errors'; +import type { UserData, UserInfo, UserProfile } from './user_profile'; +import type { UserProfileGrant } from './user_profile_grant'; const KIBANA_DATA_ROOT = 'kibana'; -export interface UserProfile { - uid: string; - user: UserInfo; - data: T; -} - -export interface UserInfo extends User { - display_name?: string; - avatar?: { - initials?: string; - color?: string; - image_url?: string; - }; - active: boolean; -} - -export type UserData = Record; - export interface UserProfileServiceStart { + /** + * Activates user profile using provided user profile grant. + * @param grant User profile grant (username/password or access token). + */ + activate(grant: UserProfileGrant): Promise; + /** * Retrieves a single user profile by identifier. * @param uid User ID @@ -59,22 +49,48 @@ type GetProfileResponse = Record< } >; +export interface UserProfileServiceStartParams { + clusterClient: IClusterClient; +} + export class UserProfileService { constructor(private readonly logger: Logger) {} - start(elasticsearchClient: ElasticsearchClient): UserProfileServiceStart { + start({ clusterClient }: UserProfileServiceStartParams): UserProfileServiceStart { const { logger } = this; + async function activate(grant: UserProfileGrant): Promise { + logger.debug(`Activating user profile via ${grant.type} grant.`); + + try { + const response = await clusterClient.asInternalUser.transport.request({ + method: 'POST', + path: '_security/profile/_activate', + body: + grant.type === 'password' + ? { grant_type: 'password', username: grant.username, password: grant.password } + : { grant_type: 'access_token', access_token: grant.accessToken }, + }); + + logger.debug(`Successfully activated profile for "${response.user.username}".`); + + return response; + } catch (err) { + logger.error(`Failed to activate user profile: ${getDetailedErrorMessage(err)}.`); + throw err; + } + } + async function get(uid: string, dataPath?: string) { try { - const { body } = await elasticsearchClient.transport.request>({ + const body = await clusterClient.asInternalUser.transport.request>({ method: 'GET', path: `_security/profile/${uid}${ dataPath ? `?data=${KIBANA_DATA_ROOT}.${dataPath}` : '' }`, }); - const { user, data } = body[uid]; - return { uid, user, data: data[KIBANA_DATA_ROOT] }; + const { user, enabled, data } = body[uid]; + return { uid, enabled, user, data: data[KIBANA_DATA_ROOT] }; } catch (error) { logger.error(`Failed to retrieve user profile [uid=${uid}]: ${error.message}`); throw error; @@ -83,7 +99,7 @@ export class UserProfileService { async function update(uid: string, data: T) { try { - await elasticsearchClient.transport.request({ + await clusterClient.asInternalUser.transport.request({ method: 'POST', path: `_security/profile/_data/${uid}`, body: { @@ -98,6 +114,6 @@ export class UserProfileService { } } - return { get, update }; + return { activate, get, update }; } } From 177542129ea8526eb814c206504ab5829ad5168b Mon Sep 17 00:00:00 2001 From: Thom Heymann <190132+thomheymann@users.noreply.github.com> Date: Tue, 24 May 2022 09:18:36 +0100 Subject: [PATCH 05/13] Redesigned user profile page (#127624) * Add user profile UI * add code doc * More * Fix nav control for anonymous users * Move presentational logic outside form row * Fix disabled state and update profile api * remove presentational logic from form row * wip * Fix initials * . * empty user avatar * simplify reserved user screen * . * unit tests * type fixes * . * . * . * . * . * . * . * . * . * . * . * . * . * . * . * . * . * [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix' * . * . * . * . * . * . * Review#1: handle UI inconsistencies, update avatar in the navigation bar when profile data is updated, etc. * Fix type errors. * Use different validation model for the first and subsequent submits. * Review#2: move change password form to a modal, support users authenticated via authentication proxies. * Use proper test subject for the password change Cancel button. * Review#3: retry profile activation requests. * Review#4: align text color of the non-editable profile fields with the designs. * Review#4: improve warning message for the change password form when changing password for Kibana system users. Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Aleh Zasypkin --- package.json | 1 + packages/kbn-optimizer/limits.yml | 2 +- renovate.json | 1 + x-pack/plugins/security/common/index.ts | 5 + .../common/model/authenticated_user.test.ts | 122 ++- .../common/model/authenticated_user.ts | 28 +- x-pack/plugins/security/common/model/index.ts | 19 +- x-pack/plugins/security/common/model/user.ts | 2 +- .../common/model/user_profile.mock.ts | 29 + .../security/common/model/user_profile.ts | 116 +++ .../account_management_app.test.ts | 81 -- .../account_management_app.test.tsx | 78 ++ .../account_management_app.ts | 50 -- .../account_management_app.tsx | 106 +++ .../account_management_page.test.tsx | 178 ++--- .../account_management_page.tsx | 111 ++- .../public/account_management/index.ts | 5 +- .../account_management/user_profile/index.ts | 13 + .../user_profile/user_avatar.tsx | 50 ++ .../user_profile/user_profile.test.tsx | 184 +++++ .../user_profile/user_profile.tsx | 694 ++++++++++++++++++ .../user_profile_api_client.test.ts | 42 ++ .../user_profile/user_profile_api_client.ts | 46 ++ .../account_management/user_profile/utils.ts | 76 ++ .../public/components/api_clients_provider.ts | 23 + .../public/components/form_changes.test.tsx | 68 ++ .../public/components/form_changes.tsx | 74 ++ .../public/components/form_field.test.tsx | 178 +++++ .../security/public/components/form_field.tsx | 125 ++++ .../public/components/form_label.test.tsx | 44 ++ .../security/public/components/form_label.tsx | 58 ++ .../public/components/form_row.test.tsx | 44 ++ .../security/public/components/form_row.tsx | 66 ++ .../security/public/components/index.ts | 15 + .../public/components/use_current_user.ts | 9 + x-pack/plugins/security/public/index.ts | 3 +- .../edit_user/change_password_flyout.tsx | 297 -------- .../users/edit_user/change_password_modal.tsx | 304 ++++++++ ...est.tsx => change_password_model.test.tsx} | 18 +- .../users/edit_user/edit_user_page.tsx | 4 +- .../management/users/edit_user/user_form.tsx | 2 +- .../nav_control/nav_control_component.scss | 11 - .../nav_control_component.test.tsx | 520 ++++++++----- .../nav_control/nav_control_component.tsx | 287 ++++---- .../nav_control/nav_control_service.test.ts | 104 +-- .../nav_control/nav_control_service.tsx | 77 +- x-pack/plugins/security/public/plugin.tsx | 15 +- .../change_password/change_password.tsx | 0 .../change_password/change_password_async.tsx | 0 .../change_password/index.ts | 0 .../security/public/ui_api/components.tsx | 4 +- .../plugins/security/public/ui_api/index.ts | 5 +- .../personal_info/index.ts | 0 .../personal_info/personal_info.tsx | 0 .../personal_info/personal_info_async.tsx | 0 .../authentication/authenticator.test.ts | 2 +- .../server/routes/user_profile/get.ts | 28 +- .../server/routes/user_profile/index.ts | 2 +- .../server/routes/user_profile/update.ts | 21 +- .../security/server/user_profile/index.ts | 1 - .../server/user_profile/user_profile.mock.ts | 18 - .../server/user_profile/user_profile.ts | 45 -- .../user_profile/user_profile_service.mock.ts | 4 +- .../user_profile/user_profile_service.test.ts | 285 +++++-- .../user_profile/user_profile_service.ts | 81 +- .../translations/translations/fr-FR.json | 21 +- .../translations/translations/ja-JP.json | 21 +- .../translations/translations/zh-CN.json | 21 +- x-pack/test/accessibility/apps/users.ts | 2 +- .../functional/apps/security/user_email.ts | 6 +- x-pack/test/functional/apps/security/users.ts | 8 +- .../page_objects/account_settings_page.ts | 30 +- .../functional/page_objects/security_page.ts | 2 +- yarn.lock | 21 +- 74 files changed, 3693 insertions(+), 1320 deletions(-) create mode 100644 x-pack/plugins/security/common/model/user_profile.mock.ts create mode 100644 x-pack/plugins/security/common/model/user_profile.ts delete mode 100644 x-pack/plugins/security/public/account_management/account_management_app.test.ts create mode 100644 x-pack/plugins/security/public/account_management/account_management_app.test.tsx delete mode 100644 x-pack/plugins/security/public/account_management/account_management_app.ts create mode 100644 x-pack/plugins/security/public/account_management/account_management_app.tsx create mode 100644 x-pack/plugins/security/public/account_management/user_profile/index.ts create mode 100644 x-pack/plugins/security/public/account_management/user_profile/user_avatar.tsx create mode 100644 x-pack/plugins/security/public/account_management/user_profile/user_profile.test.tsx create mode 100644 x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx create mode 100644 x-pack/plugins/security/public/account_management/user_profile/user_profile_api_client.test.ts create mode 100644 x-pack/plugins/security/public/account_management/user_profile/user_profile_api_client.ts create mode 100644 x-pack/plugins/security/public/account_management/user_profile/utils.ts create mode 100644 x-pack/plugins/security/public/components/api_clients_provider.ts create mode 100644 x-pack/plugins/security/public/components/form_changes.test.tsx create mode 100644 x-pack/plugins/security/public/components/form_changes.tsx create mode 100644 x-pack/plugins/security/public/components/form_field.test.tsx create mode 100644 x-pack/plugins/security/public/components/form_field.tsx create mode 100644 x-pack/plugins/security/public/components/form_label.test.tsx create mode 100644 x-pack/plugins/security/public/components/form_label.tsx create mode 100644 x-pack/plugins/security/public/components/form_row.test.tsx create mode 100644 x-pack/plugins/security/public/components/form_row.tsx create mode 100644 x-pack/plugins/security/public/components/index.ts delete mode 100644 x-pack/plugins/security/public/management/users/edit_user/change_password_flyout.tsx create mode 100644 x-pack/plugins/security/public/management/users/edit_user/change_password_modal.tsx rename x-pack/plugins/security/public/management/users/edit_user/{change_password_flyout.test.tsx => change_password_model.test.tsx} (90%) delete mode 100644 x-pack/plugins/security/public/nav_control/nav_control_component.scss rename x-pack/plugins/security/public/{account_management => ui_api}/change_password/change_password.tsx (100%) rename x-pack/plugins/security/public/{account_management => ui_api}/change_password/change_password_async.tsx (100%) rename x-pack/plugins/security/public/{account_management => ui_api}/change_password/index.ts (100%) rename x-pack/plugins/security/public/{account_management => ui_api}/personal_info/index.ts (100%) rename x-pack/plugins/security/public/{account_management => ui_api}/personal_info/personal_info.tsx (100%) rename x-pack/plugins/security/public/{account_management => ui_api}/personal_info/personal_info_async.tsx (100%) delete mode 100644 x-pack/plugins/security/server/user_profile/user_profile.mock.ts delete mode 100644 x-pack/plugins/security/server/user_profile/user_profile.ts diff --git a/package.json b/package.json index e5fffb5b3a394..7ad52d25fe6f6 100644 --- a/package.json +++ b/package.json @@ -269,6 +269,7 @@ "file-saver": "^1.3.8", "file-type": "^10.9.0", "font-awesome": "4.7.0", + "formik": "^2.2.9", "fp-ts": "^2.3.1", "geojson-vt": "^3.2.1", "get-port": "^5.0.0", diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 97e9f23784f60..2386e2aae6f43 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -52,7 +52,7 @@ pageLoadAssetSize: savedObjectsTagging: 59482 savedObjectsTaggingOss: 20590 searchprofiler: 67080 - security: 95864 + security: 100000 snapshotRestore: 79032 spaces: 57868 telemetry: 51957 diff --git a/renovate.json b/renovate.json index 628eeec7c6e35..34ef1ea5bc0cf 100644 --- a/renovate.json +++ b/renovate.json @@ -115,6 +115,7 @@ "groupName": "platform security modules", "matchPackageNames": [ "node-forge", + "formik", "@types/node-forge", "require-in-the-middle", "tough-cookie", diff --git a/x-pack/plugins/security/common/index.ts b/x-pack/plugins/security/common/index.ts index 0da855b153be8..befc5293e39d5 100644 --- a/x-pack/plugins/security/common/index.ts +++ b/x-pack/plugins/security/common/index.ts @@ -8,6 +8,7 @@ export type { SecurityLicense, SecurityLicenseFeatures, LoginLayout } from './licensing'; export type { AuthenticatedUser, + AuthenticatedUserProfile, AuthenticationProvider, PrivilegeDeprecationsService, PrivilegeDeprecationsRolesByFeatureIdRequest, @@ -17,6 +18,10 @@ export type { RoleKibanaPrivilege, FeaturesPrivileges, User, + UserProfile, + UserData, + UserAvatar, + UserInfo, ApiKey, UserRealm, } from './model'; diff --git a/x-pack/plugins/security/common/model/authenticated_user.test.ts b/x-pack/plugins/security/common/model/authenticated_user.test.ts index 86a976daf7bf6..4c84a951bf729 100644 --- a/x-pack/plugins/security/common/model/authenticated_user.test.ts +++ b/x-pack/plugins/security/common/model/authenticated_user.test.ts @@ -5,8 +5,128 @@ * 2.0. */ +import { applicationServiceMock } from '@kbn/core/public/mocks'; + import type { AuthenticatedUser } from './authenticated_user'; -import { canUserChangePassword } from './authenticated_user'; +import { + canUserChangeDetails, + canUserChangePassword, + canUserHaveProfile, + isUserAnonymous, +} from './authenticated_user'; +import { mockAuthenticatedUser } from './authenticated_user.mock'; + +describe('canUserChangeDetails', () => { + const { capabilities } = applicationServiceMock.createStartContract(); + + it('should indicate when user can change their details', () => { + expect( + canUserChangeDetails( + mockAuthenticatedUser({ + authentication_realm: { type: 'native', name: 'native1' }, + }), + { + ...capabilities, + management: { + security: { + users: true, + }, + }, + } + ) + ).toBe(true); + }); + + it('should indicate when user cannot change their details', () => { + expect( + canUserChangeDetails( + mockAuthenticatedUser({ + authentication_realm: { type: 'native', name: 'native1' }, + }), + { + ...capabilities, + management: { + security: { + users: false, + }, + }, + } + ) + ).toBe(false); + + expect( + canUserChangeDetails( + mockAuthenticatedUser({ + authentication_realm: { type: 'reserved', name: 'reserved1' }, + }), + { + ...capabilities, + management: { + security: { + users: true, + }, + }, + } + ) + ).toBe(false); + }); +}); + +describe('isUserAnonymous', () => { + it('should indicate anonymous user', () => { + expect( + isUserAnonymous( + mockAuthenticatedUser({ + authentication_provider: { type: 'anonymous', name: 'basic1' }, + }) + ) + ).toBe(true); + }); + + it('should indicate non-anonymous user', () => { + expect( + isUserAnonymous( + mockAuthenticatedUser({ + authentication_provider: { type: 'basic', name: 'basic1' }, + }) + ) + ).toBe(false); + }); +}); + +describe('canUserHaveProfile', () => { + it('anonymous users cannot have profiles', () => { + expect( + canUserHaveProfile( + mockAuthenticatedUser({ + authentication_provider: { type: 'anonymous', name: 'basic1' }, + }) + ) + ).toBe(false); + }); + + it('proxy authenticated users cannot have profiles', () => { + expect( + canUserHaveProfile( + mockAuthenticatedUser({ + authentication_provider: { type: 'http', name: '__http__' }, + }) + ) + ).toBe(false); + }); + + it('non-anonymous users that can have sessions can have profiles', () => { + for (const providerType of ['saml', 'oidc', 'basic', 'token', 'pki', 'kerberos']) { + expect( + canUserHaveProfile( + mockAuthenticatedUser({ + authentication_provider: { type: providerType, name: `${providerType}_name` }, + }) + ) + ).toBe(true); + } + }); +}); describe('#canUserChangePassword', () => { ['reserved', 'native'].forEach((realm) => { diff --git a/x-pack/plugins/security/common/model/authenticated_user.ts b/x-pack/plugins/security/common/model/authenticated_user.ts index d9fabc25df5ed..708cb00fbca50 100644 --- a/x-pack/plugins/security/common/model/authenticated_user.ts +++ b/x-pack/plugins/security/common/model/authenticated_user.ts @@ -5,6 +5,8 @@ * 2.0. */ +import type { Capabilities } from '@kbn/core/types'; + import type { AuthenticationProvider } from './authentication_provider'; import type { User } from './user'; @@ -42,9 +44,31 @@ export interface AuthenticatedUser extends User { authentication_type: string; } -export function canUserChangePassword(user: AuthenticatedUser) { +export function isUserAnonymous(user: Pick) { + return user.authentication_provider.type === 'anonymous'; +} + +/** + * All users are supposed to have profiles except anonymous users and users authenticated + * via authentication HTTP proxies. + * @param user Authenticated user information. + */ +export function canUserHaveProfile(user: AuthenticatedUser) { + return !isUserAnonymous(user) && user.authentication_provider.type !== 'http'; +} + +export function canUserChangePassword( + user: Pick +) { return ( REALMS_ELIGIBLE_FOR_PASSWORD_CHANGE.includes(user.authentication_realm.type) && - user.authentication_provider.type !== 'anonymous' + !isUserAnonymous(user) ); } + +export function canUserChangeDetails( + user: Pick, + capabilities: Capabilities +) { + return user.authentication_realm.type === 'native' && capabilities.management.security.users; +} diff --git a/x-pack/plugins/security/common/model/index.ts b/x-pack/plugins/security/common/model/index.ts index 84d7f261e51a7..d655b23c0b439 100644 --- a/x-pack/plugins/security/common/model/index.ts +++ b/x-pack/plugins/security/common/model/index.ts @@ -7,9 +7,26 @@ export type { ApiKey, ApiKeyToInvalidate, ApiKeyRoleDescriptors } from './api_key'; export type { User, EditUser } from './user'; +export type { + AuthenticatedUserProfile, + UserProfile, + UserData, + UserInfo, + UserAvatar, +} from './user_profile'; +export { + getUserAvatarColor, + getUserAvatarInitials, + USER_AVATAR_MAX_INITIALS, +} from './user_profile'; export { getUserDisplayName } from './user'; export type { AuthenticatedUser, UserRealm } from './authenticated_user'; -export { canUserChangePassword } from './authenticated_user'; +export { + canUserChangePassword, + canUserChangeDetails, + isUserAnonymous, + canUserHaveProfile, +} from './authenticated_user'; export type { AuthenticationProvider } from './authentication_provider'; export { shouldProviderUseLoginForm } from './authentication_provider'; export type { BuiltinESPrivileges } from './builtin_es_privileges'; diff --git a/x-pack/plugins/security/common/model/user.ts b/x-pack/plugins/security/common/model/user.ts index 2bcea659699cb..0501b265d0631 100644 --- a/x-pack/plugins/security/common/model/user.ts +++ b/x-pack/plugins/security/common/model/user.ts @@ -23,6 +23,6 @@ export interface EditUser extends User { confirmPassword?: string; } -export function getUserDisplayName(user: User) { +export function getUserDisplayName(user: Pick) { return user.full_name || user.username; } diff --git a/x-pack/plugins/security/common/model/user_profile.mock.ts b/x-pack/plugins/security/common/model/user_profile.mock.ts new file mode 100644 index 0000000000000..fa6f34a1b603e --- /dev/null +++ b/x-pack/plugins/security/common/model/user_profile.mock.ts @@ -0,0 +1,29 @@ +/* + * 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 { mockAuthenticatedUser } from './authenticated_user.mock'; +import type { AuthenticatedUserProfile } from './user_profile'; + +export const userProfileMock = { + create: (userProfile: Partial = {}): AuthenticatedUserProfile => { + const user = mockAuthenticatedUser({ + username: 'some-username', + roles: [], + enabled: true, + }); + return { + uid: 'some-profile-uid', + enabled: true, + user: { + ...user, + active: true, + }, + data: {}, + ...userProfile, + }; + }, +}; diff --git a/x-pack/plugins/security/common/model/user_profile.ts b/x-pack/plugins/security/common/model/user_profile.ts new file mode 100644 index 0000000000000..fc5489ff0a173 --- /dev/null +++ b/x-pack/plugins/security/common/model/user_profile.ts @@ -0,0 +1,116 @@ +/* + * 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 { VISUALIZATION_COLORS } from '@elastic/eui'; + +import type { User } from '..'; +import type { AuthenticatedUser } from './authenticated_user'; +import { getUserDisplayName } from './user'; + +/** + * User information returned in user profile. + */ +export interface UserInfo extends User { + active: boolean; +} + +/** + * Avatar stored in user profile. + */ +export interface UserAvatar { + initials?: string; + color?: string; + imageUrl?: string; +} + +/** + * Placeholder for data stored in user profile. + */ +export type UserData = Record; + +/** + * Describes properties stored in user profile. + */ +export interface UserProfile { + /** + * Unique ID for of the user profile. + */ + uid: string; + + /** + * Indicates whether user profile is enabled or not. + */ + enabled: boolean; + + /** + * Information about the user that owns profile. + */ + user: UserInfo; + + /** + * User specific data associated with the profile. + */ + data: T; +} + +/** + * User profile enriched with session information. + */ +export interface AuthenticatedUserProfile extends UserProfile { + /** + * Information about the currently authenticated user that owns the profile. + */ + user: UserProfile['user'] & Pick; +} + +export const USER_AVATAR_FALLBACK_CODE_POINT = 97; // code point for lowercase "a" +export const USER_AVATAR_MAX_INITIALS = 2; + +/** + * Determines the color for the provided user profile. + * If a color is present on the user profile itself, then that is used. + * Otherwise, a color is provided from EUI's Visualization Colors based on the display name. + * + * @param {UserInfo} user User info + * @param {UserAvatar} avatar User avatar + */ +export function getUserAvatarColor( + user: Pick, + avatar?: UserAvatar +) { + if (avatar && avatar.color) { + return avatar.color; + } + + const firstCodePoint = getUserDisplayName(user).codePointAt(0) || USER_AVATAR_FALLBACK_CODE_POINT; + + return VISUALIZATION_COLORS[firstCodePoint % VISUALIZATION_COLORS.length]; +} + +/** + * Determines the initials for the provided user profile. + * If initials are present on the user profile itself, then that is used. + * Otherwise, the initials are calculated based off the words in the display name, with a max length of 2 characters. + * + * @param {UserInfo} user User info + * @param {UserAvatar} avatar User avatar + */ +export function getUserAvatarInitials( + user: Pick, + avatar?: UserAvatar +) { + if (avatar && avatar.initials) { + return avatar.initials; + } + + const words = getUserDisplayName(user).split(' '); + const numInitials = Math.min(USER_AVATAR_MAX_INITIALS, words.length); + + words.splice(numInitials, words.length); + + return words.map((word) => word.substring(0, 1)).join(''); +} diff --git a/x-pack/plugins/security/public/account_management/account_management_app.test.ts b/x-pack/plugins/security/public/account_management/account_management_app.test.ts deleted file mode 100644 index 3ebeefd9c945b..0000000000000 --- a/x-pack/plugins/security/public/account_management/account_management_app.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -/* - * 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. - */ - -jest.mock('./account_management_page'); - -import type { AppMount } from '@kbn/core/public'; -import { AppNavLinkStatus } from '@kbn/core/public'; -import { coreMock, scopedHistoryMock, themeServiceMock } from '@kbn/core/public/mocks'; - -import { UserAPIClient } from '../management'; -import { securityMock } from '../mocks'; -import { accountManagementApp } from './account_management_app'; - -describe('accountManagementApp', () => { - it('properly registers application', () => { - const coreSetupMock = coreMock.createSetup(); - - accountManagementApp.create({ - application: coreSetupMock.application, - getStartServices: coreSetupMock.getStartServices, - authc: securityMock.createSetup().authc, - }); - - expect(coreSetupMock.application.register).toHaveBeenCalledTimes(1); - - const [[appRegistration]] = coreSetupMock.application.register.mock.calls; - expect(appRegistration).toEqual({ - id: 'security_account', - appRoute: '/security/account', - navLinkStatus: AppNavLinkStatus.hidden, - title: 'Account Management', - mount: expect.any(Function), - }); - }); - - it('properly sets breadcrumbs and renders application', async () => { - const coreSetupMock = coreMock.createSetup(); - const coreStartMock = coreMock.createStart(); - coreSetupMock.getStartServices.mockResolvedValue([coreStartMock, {}, {}]); - - const authcMock = securityMock.createSetup().authc; - - accountManagementApp.create({ - application: coreSetupMock.application, - getStartServices: coreSetupMock.getStartServices, - authc: authcMock, - }); - - const [[{ mount }]] = coreSetupMock.application.register.mock.calls; - const appMountParams = { - element: document.createElement('div'), - appBasePath: '', - onAppLeave: jest.fn(), - setHeaderActionMenu: jest.fn(), - history: scopedHistoryMock.create(), - theme$: themeServiceMock.createTheme$(), - }; - await (mount as AppMount)(appMountParams); - - expect(coreStartMock.chrome.setBreadcrumbs).toHaveBeenCalledTimes(1); - expect(coreStartMock.chrome.setBreadcrumbs).toHaveBeenCalledWith([ - { text: 'Account Management' }, - ]); - - const mockRenderApp = jest.requireMock('./account_management_page').renderAccountManagementPage; - expect(mockRenderApp).toHaveBeenCalledTimes(1); - expect(mockRenderApp).toHaveBeenCalledWith( - coreStartMock.i18n, - { element: appMountParams.element, theme$: appMountParams.theme$ }, - { - userAPIClient: expect.any(UserAPIClient), - authc: authcMock, - notifications: coreStartMock.notifications, - } - ); - }); -}); diff --git a/x-pack/plugins/security/public/account_management/account_management_app.test.tsx b/x-pack/plugins/security/public/account_management/account_management_app.test.tsx new file mode 100644 index 0000000000000..00f245a77febf --- /dev/null +++ b/x-pack/plugins/security/public/account_management/account_management_app.test.tsx @@ -0,0 +1,78 @@ +/* + * 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 { act } from '@testing-library/react'; +import { noop } from 'lodash'; + +import type { AppUnmount } from '@kbn/core/public'; +import { AppNavLinkStatus } from '@kbn/core/public'; +import { coreMock, scopedHistoryMock, themeServiceMock } from '@kbn/core/public/mocks'; + +import { UserAPIClient } from '../management'; +import { securityMock } from '../mocks'; +import { accountManagementApp } from './account_management_app'; +import * as AccountManagementPageImports from './account_management_page'; +import { UserProfileAPIClient } from './user_profile'; + +const AccountManagementPageMock = jest + .spyOn(AccountManagementPageImports, 'AccountManagementPage') + .mockReturnValue(null); + +describe('accountManagementApp', () => { + it('should register application', () => { + const { authc } = securityMock.createSetup(); + const { application, getStartServices, http } = coreMock.createSetup(); + + accountManagementApp.create({ + application, + getStartServices, + authc, + apiClients: { userProfiles: new UserProfileAPIClient(http), users: new UserAPIClient(http) }, + }); + + expect(application.register).toHaveBeenCalledTimes(1); + expect(application.register).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'security_account', + appRoute: '/security/account', + navLinkStatus: AppNavLinkStatus.hidden, + mount: expect.any(Function), + }) + ); + }); + + it('should render AccountManagementPage on mount', async () => { + const { authc } = securityMock.createSetup(); + const { application, getStartServices, http } = coreMock.createSetup(); + getStartServices.mockResolvedValue([coreMock.createStart(), {}, {}]); + + accountManagementApp.create({ + application, + authc, + getStartServices, + apiClients: { userProfiles: new UserProfileAPIClient(http), users: new UserAPIClient(http) }, + }); + + const [[{ mount }]] = application.register.mock.calls; + + let unmount: AppUnmount = noop; + await act(async () => { + unmount = await mount({ + element: document.createElement('div'), + appBasePath: '', + onAppLeave: jest.fn(), + setHeaderActionMenu: jest.fn(), + history: scopedHistoryMock.create(), + theme$: themeServiceMock.createTheme$(), + }); + }); + + expect(AccountManagementPageMock).toHaveBeenCalledTimes(1); + + unmount(); + }); +}); diff --git a/x-pack/plugins/security/public/account_management/account_management_app.ts b/x-pack/plugins/security/public/account_management/account_management_app.ts deleted file mode 100644 index 98d810805a16c..0000000000000 --- a/x-pack/plugins/security/public/account_management/account_management_app.ts +++ /dev/null @@ -1,50 +0,0 @@ -/* - * 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 type { ApplicationSetup, AppMountParameters, StartServicesAccessor } from '@kbn/core/public'; -import { AppNavLinkStatus } from '@kbn/core/public'; -import { i18n } from '@kbn/i18n'; - -import type { AuthenticationServiceSetup } from '../authentication'; - -interface CreateDeps { - application: ApplicationSetup; - authc: AuthenticationServiceSetup; - getStartServices: StartServicesAccessor; -} - -export const accountManagementApp = Object.freeze({ - id: 'security_account', - create({ application, authc, getStartServices }: CreateDeps) { - const title = i18n.translate('xpack.security.account.breadcrumb', { - defaultMessage: 'Account Management', - }); - application.register({ - id: this.id, - title, - navLinkStatus: AppNavLinkStatus.hidden, - appRoute: '/security/account', - async mount({ element, theme$ }: AppMountParameters) { - const [[coreStart], { renderAccountManagementPage }, { UserAPIClient }] = await Promise.all( - [getStartServices(), import('./account_management_page'), import('../management')] - ); - - coreStart.chrome.setBreadcrumbs([{ text: title }]); - - return renderAccountManagementPage( - coreStart.i18n, - { element, theme$ }, - { - authc, - notifications: coreStart.notifications, - userAPIClient: new UserAPIClient(coreStart.http), - } - ); - }, - }); - }, -}); diff --git a/x-pack/plugins/security/public/account_management/account_management_app.tsx b/x-pack/plugins/security/public/account_management/account_management_app.tsx new file mode 100644 index 0000000000000..104cc88591b4e --- /dev/null +++ b/x-pack/plugins/security/public/account_management/account_management_app.tsx @@ -0,0 +1,106 @@ +/* + * 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 type { History } from 'history'; +import type { FunctionComponent } from 'react'; +import React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { Router } from 'react-router-dom'; +import type { Observable } from 'rxjs'; + +import type { + ApplicationSetup, + AppMountParameters, + CoreStart, + CoreTheme, + StartServicesAccessor, +} from '@kbn/core/public'; +import { AppNavLinkStatus } from '@kbn/core/public'; +import { i18n } from '@kbn/i18n'; +import { I18nProvider } from '@kbn/i18n-react'; +import { KibanaContextProvider, KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; + +import type { AuthenticationServiceSetup } from '../authentication'; +import type { ApiClients } from '../components'; +import { ApiClientsProvider, AuthenticationProvider } from '../components'; +import type { BreadcrumbsChangeHandler } from '../components/breadcrumb'; +import { BreadcrumbsProvider } from '../components/breadcrumb'; + +interface CreateDeps { + application: ApplicationSetup; + authc: AuthenticationServiceSetup; + apiClients: ApiClients; + getStartServices: StartServicesAccessor; +} + +export const accountManagementApp = Object.freeze({ + id: 'security_account', + create({ application, authc, getStartServices, apiClients }: CreateDeps) { + application.register({ + id: this.id, + title: i18n.translate('xpack.security.account.breadcrumb', { + defaultMessage: 'User settings', + }), + navLinkStatus: AppNavLinkStatus.hidden, + appRoute: '/security/account', + async mount({ element, theme$, history }: AppMountParameters) { + const [[coreStart], { AccountManagementPage }] = await Promise.all([ + getStartServices(), + import('./account_management_page'), + ]); + + render( + + + , + element + ); + + return () => unmountComponentAtNode(element); + }, + }); + }, +}); + +export interface ProvidersProps { + services: CoreStart; + theme$: Observable; + history: History; + authc: AuthenticationServiceSetup; + apiClients: ApiClients; + onChange?: BreadcrumbsChangeHandler; +} + +export const Providers: FunctionComponent = ({ + services, + theme$, + history, + authc, + apiClients, + onChange, + children, +}) => ( + + + + + + + {children} + + + + + + +); diff --git a/x-pack/plugins/security/public/account_management/account_management_page.test.tsx b/x-pack/plugins/security/public/account_management/account_management_page.test.tsx index 976f437beaa6b..a75d7192089b4 100644 --- a/x-pack/plugins/security/public/account_management/account_management_page.test.tsx +++ b/x-pack/plugins/security/public/account_management/account_management_page.test.tsx @@ -5,142 +5,74 @@ * 2.0. */ -import { act } from '@testing-library/react'; +import { render } from '@testing-library/react'; import React from 'react'; -import { coreMock } from '@kbn/core/public/mocks'; -import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers'; +import { coreMock, scopedHistoryMock, themeServiceMock } from '@kbn/core/public/mocks'; -import type { AuthenticatedUser } from '../../common/model'; +import type { UserData } from '../../common'; import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock'; -import { userAPIClientMock } from '../management/users/index.mock'; +import { UserAPIClient } from '../management'; import { securityMock } from '../mocks'; +import { Providers } from './account_management_app'; import { AccountManagementPage } from './account_management_page'; +import { UserProfileAPIClient } from './user_profile'; +import * as UserProfileImports from './user_profile/user_profile'; -interface Options { - withFullName?: boolean; - withEmail?: boolean; - realm?: string; -} -const createUser = ({ withFullName = true, withEmail = true, realm = 'native' }: Options = {}) => { - return mockAuthenticatedUser({ - full_name: withFullName ? 'Casey Smith' : '', - username: 'csmith', - email: withEmail ? 'csmith@domain.com' : '', - roles: [], - authentication_realm: { - type: realm, - name: realm, - }, - lookup_realm: { - type: realm, - name: realm, - }, - }); -}; - -function getSecuritySetupMock({ currentUser }: { currentUser: AuthenticatedUser }) { - const securitySetupMock = securityMock.createSetup(); - securitySetupMock.authc.getCurrentUser.mockResolvedValue(currentUser); - return securitySetupMock; -} +const UserProfileMock = jest.spyOn(UserProfileImports, 'UserProfile'); describe('', () => { - it(`displays users full name, username, and email address`, async () => { - const user = createUser(); - const wrapper = mountWithIntl( - - ); - - await act(async () => { - await nextTick(); - wrapper.update(); - }); - - expect(wrapper.find('EuiText[data-test-subj="userDisplayName"]').text()).toEqual( - `Settings for ${user.full_name}` - ); - expect(wrapper.find('[data-test-subj="username"]').text()).toEqual(user.username); - expect(wrapper.find('[data-test-subj="email"]').text()).toEqual(user.email); - }); - - it(`displays username when full_name is not provided`, async () => { - const user = createUser({ withFullName: false }); - const wrapper = mountWithIntl( - - ); - - await act(async () => { - await nextTick(); - wrapper.update(); - }); - - expect(wrapper.find('EuiText[data-test-subj="userDisplayName"]').text()).toEqual( - `Settings for ${user.username}` - ); - }); - - it(`displays a placeholder when no email address is provided`, async () => { - const user = createUser({ withEmail: false }); - const wrapper = mountWithIntl( - - ); - - await act(async () => { - await nextTick(); - wrapper.update(); - }); - - expect(wrapper.find('[data-test-subj="email"]').text()).toEqual('no email address'); - }); - - it(`displays change password form for users in the native realm`, async () => { - const user = createUser(); - const wrapper = mountWithIntl( - - ); - - await act(async () => { - await nextTick(); - wrapper.update(); - }); - - expect(wrapper.find('EuiFieldPassword[data-test-subj="currentPassword"]')).toHaveLength(1); - expect(wrapper.find('EuiFieldPassword[data-test-subj="newPassword"]')).toHaveLength(1); + const coreStart = coreMock.createStart(); + // @ts-ignore Capabilities are marked as readonly without a way of overriding. + coreStart.application.capabilities = { + management: { + security: { + users: true, + }, + }, + }; + const theme$ = themeServiceMock.createTheme$(); + let history = scopedHistoryMock.create(); + const authc = securityMock.createSetup().authc; + + beforeEach(() => { + history = scopedHistoryMock.create(); + authc.getCurrentUser.mockClear(); + coreStart.http.delete.mockClear(); + coreStart.http.get.mockClear(); + coreStart.http.post.mockClear(); + coreStart.notifications.toasts.addDanger.mockClear(); + coreStart.notifications.toasts.addSuccess.mockClear(); }); - it(`does not display change password form for users in the saml realm`, async () => { - const user = createUser({ realm: 'saml' }); - const wrapper = mountWithIntl( - + it('should render user profile form and set breadcrumbs', async () => { + const user = mockAuthenticatedUser(); + const data: UserData = {}; + + authc.getCurrentUser.mockResolvedValue(user); + coreStart.http.get.mockResolvedValue({ user, data }); + + const { findByRole } = render( + + + ); - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await findByRole('form'); - expect(wrapper.find('EuiFieldText[data-test-subj="currentPassword"]')).toHaveLength(0); - expect(wrapper.find('EuiFieldText[data-test-subj="newPassword"]')).toHaveLength(0); + expect(UserProfileMock).toHaveBeenCalledWith({ user, data }, expect.anything()); + expect(coreStart.chrome.setBreadcrumbs).toHaveBeenLastCalledWith([ + { href: '/security/account', text: 'User settings' }, + { href: undefined, text: 'Profile' }, + ]); }); }); diff --git a/x-pack/plugins/security/public/account_management/account_management_page.tsx b/x-pack/plugins/security/public/account_management/account_management_page.tsx index 5eb396204b6e8..216ec8a4190b8 100644 --- a/x-pack/plugins/security/public/account_management/account_management_page.tsx +++ b/x-pack/plugins/security/public/account_management/account_management_page.tsx @@ -5,80 +5,51 @@ * 2.0. */ -import { EuiPage, EuiPageBody, EuiPanel, EuiSpacer, EuiText } from '@elastic/eui'; -import React, { useEffect, useState } from 'react'; -import ReactDOM from 'react-dom'; - -import type { AppMountParameters, CoreStart, NotificationsStart } from '@kbn/core/public'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; -import type { PublicMethodsOf } from '@kbn/utility-types'; - -import type { AuthenticatedUser } from '../../common/model'; -import { getUserDisplayName } from '../../common/model'; -import type { AuthenticationServiceSetup } from '../authentication'; -import type { UserAPIClient } from '../management'; -import { ChangePassword } from './change_password'; -import { PersonalInfo } from './personal_info'; - -interface Props { - authc: AuthenticationServiceSetup; - userAPIClient: PublicMethodsOf; - notifications: NotificationsStart; -} - -export const AccountManagementPage = ({ userAPIClient, authc, notifications }: Props) => { - const [currentUser, setCurrentUser] = useState(null); - useEffect(() => { - authc.getCurrentUser().then(setCurrentUser); - }, [authc]); +import { EuiEmptyPrompt } from '@elastic/eui'; +import type { FunctionComponent } from 'react'; +import React from 'react'; + +import type { CoreStart } from '@kbn/core/public'; +import { i18n } from '@kbn/i18n'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; + +import type { UserAvatar } from '../../common'; +import { canUserHaveProfile } from '../../common/model'; +import { useCurrentUser, useUserProfile } from '../components'; +import { Breadcrumb } from '../components/breadcrumb'; +import { UserProfile } from './user_profile'; + +export const AccountManagementPage: FunctionComponent = () => { + const { services } = useKibana(); + + const currentUser = useCurrentUser(); + const userProfile = useUserProfile<{ avatar: UserAvatar }>('avatar'); + + // If we fail to load profile, we treat it as a failure _only_ if user is supposed + // to have a profile. For example, anonymous and users authenticated via + // authentication proxies don't have profiles. + const profileLoadError = + userProfile.error && currentUser.value && canUserHaveProfile(currentUser.value) + ? userProfile.error + : undefined; + + const error = currentUser.error || profileLoadError; + if (error) { + return {error.message}} />; + } - if (!currentUser) { + if (!currentUser.value || (canUserHaveProfile(currentUser.value) && !userProfile.value)) { return null; } return ( - - - - -

- {getUserDisplayName(currentUser)} }} - /> -

-
- - - - - - -
-
-
+ + + ); }; - -export function renderAccountManagementPage( - i18nStart: CoreStart['i18n'], - { element, theme$ }: Pick, - props: Props -) { - ReactDOM.render( - - - - - , - element - ); - - return () => ReactDOM.unmountComponentAtNode(element); -} diff --git a/x-pack/plugins/security/public/account_management/index.ts b/x-pack/plugins/security/public/account_management/index.ts index 2d1045723a6e1..a05537136602a 100644 --- a/x-pack/plugins/security/public/account_management/index.ts +++ b/x-pack/plugins/security/public/account_management/index.ts @@ -6,6 +6,5 @@ */ export { accountManagementApp } from './account_management_app'; - -export type { ChangePasswordProps } from './change_password'; -export type { PersonalInfoProps } from './personal_info'; +export type { UserAvatarProps } from './user_profile'; +export { UserProfileAPIClient, UserAvatar } from './user_profile'; diff --git a/x-pack/plugins/security/public/account_management/user_profile/index.ts b/x-pack/plugins/security/public/account_management/user_profile/index.ts new file mode 100644 index 0000000000000..f8a8f953aa833 --- /dev/null +++ b/x-pack/plugins/security/public/account_management/user_profile/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { UserProfile } from './user_profile'; +export { UserAvatar } from './user_avatar'; + +export type { UserProfileProps, UserProfileFormValues } from './user_profile'; +export type { UserAvatarProps } from './user_avatar'; +export { UserProfileAPIClient } from './user_profile_api_client'; diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_avatar.tsx b/x-pack/plugins/security/public/account_management/user_profile/user_avatar.tsx new file mode 100644 index 0000000000000..d6c2621ca45a2 --- /dev/null +++ b/x-pack/plugins/security/public/account_management/user_profile/user_avatar.tsx @@ -0,0 +1,50 @@ +/* + * 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 type { EuiAvatarProps } from '@elastic/eui'; +import { EuiAvatar, useEuiTheme } from '@elastic/eui'; +import type { FunctionComponent, HTMLAttributes } from 'react'; +import React from 'react'; + +import type { UserAvatar as IUserAvatar, UserInfo } from '../../../common'; +import { + getUserAvatarColor, + getUserAvatarInitials, + getUserDisplayName, + USER_AVATAR_MAX_INITIALS, +} from '../../../common/model'; + +export interface UserAvatarProps extends Omit, 'color'> { + user?: Pick; + avatar?: IUserAvatar; + size?: EuiAvatarProps['size']; + isDisabled?: EuiAvatarProps['isDisabled']; +} + +export const UserAvatar: FunctionComponent = ({ user, avatar, ...rest }) => { + const { euiTheme } = useEuiTheme(); + + if (!user) { + return ; + } + + const displayName = getUserDisplayName(user); + + if (avatar?.imageUrl) { + return ; + } + + return ( + + ); +}; diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_profile.test.tsx b/x-pack/plugins/security/public/account_management/user_profile/user_profile.test.tsx new file mode 100644 index 0000000000000..2861430d90353 --- /dev/null +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile.test.tsx @@ -0,0 +1,184 @@ +/* + * 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 { act, renderHook } from '@testing-library/react-hooks'; +import type { FunctionComponent } from 'react'; +import React from 'react'; + +import { coreMock, scopedHistoryMock, themeServiceMock } from '@kbn/core/public/mocks'; + +import { UserProfileAPIClient } from '..'; +import type { UserData } from '../../../common'; +import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; +import { UserAPIClient } from '../../management'; +import { securityMock } from '../../mocks'; +import { Providers } from '../account_management_app'; +import { useUserProfileForm } from './user_profile'; + +const user = mockAuthenticatedUser(); +const coreStart = coreMock.createStart(); +const theme$ = themeServiceMock.createTheme$(); +let history = scopedHistoryMock.create(); +const authc = securityMock.createSetup().authc; + +const wrapper: FunctionComponent = ({ children }) => ( + + {children} + +); + +describe('useUserProfileForm', () => { + beforeEach(() => { + history = scopedHistoryMock.create(); + authc.getCurrentUser.mockReset(); + // @ts-ignore Capabilities are marked as readonly without a way of overriding. + coreStart.application.capabilities = { + management: { + security: { + users: true, + }, + }, + }; + coreStart.http.delete.mockReset(); + coreStart.http.get.mockReset(); + coreStart.http.post.mockReset().mockResolvedValue(undefined); + coreStart.notifications.toasts.addDanger.mockReset(); + coreStart.notifications.toasts.addSuccess.mockReset(); + }); + + it('should initialise form with values from user profile', () => { + const data: UserData = { + avatar: {}, + }; + const { result } = renderHook(() => useUserProfileForm({ user, data }), { wrapper }); + + expect(result.current.values).toMatchInlineSnapshot(` + Object { + "avatarType": "initials", + "data": Object { + "avatar": Object { + "color": "#D36086", + "imageUrl": "", + "initials": "fn", + }, + }, + "user": Object { + "email": "email", + "full_name": "full name", + }, + } + `); + }); + + it('should initialise form with values from user avatar if present', () => { + const data: UserData = { + avatar: { + imageUrl: 'avatar.png', + }, + }; + const { result } = renderHook(() => useUserProfileForm({ user, data }), { wrapper }); + + expect(result.current.values).toEqual( + expect.objectContaining({ + avatarType: 'image', + data: expect.objectContaining({ + avatar: expect.objectContaining({ + imageUrl: 'avatar.png', + }), + }), + }) + ); + }); + + it('should update initials when full name changes', async () => { + const data: UserData = {}; + const { result } = renderHook(() => useUserProfileForm({ user, data }), { wrapper }); + + await act(async () => { + await result.current.setFieldValue('user.full_name', 'Another Name'); + }); + + expect(result.current.values.user.full_name).toEqual('Another Name'); + expect(result.current.values.data?.avatar.initials).toEqual('AN'); + }); + + it('should save user and user profile when submitting form', async () => { + const data: UserData = {}; + const { result } = renderHook(() => useUserProfileForm({ user, data }), { wrapper }); + + await act(async () => { + await result.current.submitForm(); + }); + + expect(coreStart.http.post).toHaveBeenCalledTimes(2); + }); + + it("should save user profile only when user details can't be updated", async () => { + // @ts-ignore Capabilities are marked as readonly without a way of overriding. + coreStart.application.capabilities = { + management: { + security: { + users: false, + }, + }, + }; + + const data: UserData = {}; + const { result } = renderHook(() => useUserProfileForm({ user, data }), { wrapper }); + + await act(async () => { + await result.current.submitForm(); + }); + + expect(coreStart.http.post).toHaveBeenCalledTimes(1); + }); + + it('should add toast after submitting form successfully', async () => { + const data: UserData = {}; + const { result } = renderHook(() => useUserProfileForm({ user, data }), { wrapper }); + + await act(async () => { + await result.current.submitForm(); + }); + + expect(coreStart.notifications.toasts.addSuccess).toHaveBeenCalledTimes(1); + }); + + it('should add toast after submitting form failed', async () => { + const data: UserData = {}; + const { result } = renderHook(() => useUserProfileForm({ user, data }), { wrapper }); + + coreStart.http.post.mockRejectedValue(new Error('Error')); + + await act(async () => { + await result.current.submitForm(); + }); + + expect(coreStart.notifications.toasts.addError).toHaveBeenCalledTimes(1); + }); + + it('should set initial values to current values after submitting form successfully', async () => { + const data: UserData = {}; + const { result } = renderHook(() => useUserProfileForm({ user, data }), { wrapper }); + + await act(async () => { + await result.current.setFieldValue('user.full_name', 'Another Name'); + await result.current.submitForm(); + }); + + expect(result.current.initialValues.user.full_name).toEqual('Another Name'); + }); +}); diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx new file mode 100644 index 0000000000000..c5d8f852ac183 --- /dev/null +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx @@ -0,0 +1,694 @@ +/* + * 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 { + EuiButton, + EuiButtonEmpty, + EuiButtonGroup, + EuiColorPicker, + EuiDescribedFormGroup, + EuiDescriptionList, + EuiFilePicker, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiIcon, + EuiIconTip, + EuiPageTemplate, + EuiSpacer, + EuiText, + useEuiTheme, + useGeneratedHtmlId, +} from '@elastic/eui'; +import { Form, FormikProvider, useFormik, useFormikContext } from 'formik'; +import type { FunctionComponent } from 'react'; +import React, { useRef, useState } from 'react'; +import useUpdateEffect from 'react-use/lib/useUpdateEffect'; + +import type { CoreStart } from '@kbn/core/public'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; + +import type { AuthenticatedUser, UserAvatar as IUserAvatar } from '../../../common'; +import { + canUserChangeDetails, + canUserChangePassword, + getUserAvatarColor, + getUserAvatarInitials, +} from '../../../common/model'; +import { useApiClients } from '../../components'; +import { Breadcrumb } from '../../components/breadcrumb'; +import { + FormChangesProvider, + useFormChanges, + useFormChangesContext, +} from '../../components/form_changes'; +import { FormField } from '../../components/form_field'; +import { FormLabel } from '../../components/form_label'; +import { FormRow, OptionalText } from '../../components/form_row'; +import { ChangePasswordModal } from '../../management/users/edit_user/change_password_modal'; +import { isUserReserved } from '../../management/users/user_utils'; +import { UserAvatar } from './user_avatar'; +import { createImageHandler, getRandomColor, IMAGE_FILE_TYPES, VALID_HEX_COLOR } from './utils'; + +export interface UserProfileProps { + user: AuthenticatedUser; + data?: { + avatar?: IUserAvatar; + }; +} + +export interface UserProfileFormValues { + user: { + full_name: string; + email: string; + }; + data?: { + avatar: { + initials: string; + color: string; + imageUrl: string; + }; + }; + avatarType: 'initials' | 'image'; +} + +export const UserProfile: FunctionComponent = ({ user, data }) => { + const { euiTheme } = useEuiTheme(); + const { services } = useKibana(); + const formik = useUserProfileForm({ user, data }); + const formChanges = useFormChanges(); + const titleId = useGeneratedHtmlId(); + const [showChangePasswordForm, setShowChangePasswordForm] = useState(false); + + const isReservedUser = isUserReserved(user); + const canChangePassword = canUserChangePassword(user); + const canChangeDetails = canUserChangeDetails(user, services.application.capabilities); + + const rightSideItems = [ + { + title: ( + + ), + description: user.username, + helpText: ( + + ), + testSubj: 'username', + }, + ]; + + if (!canChangeDetails) { + if (user.full_name) { + rightSideItems.push({ + title: ( + + ), + description: user.full_name, + helpText: ( + + ), + testSubj: 'full_name', + }); + } + + if (user.email) { + rightSideItems.push({ + title: ( + + ), + description: user.email, + helpText: ( + + ), + testSubj: 'email', + }); + } + } + + return ( + + + + {showChangePasswordForm ? ( + setShowChangePasswordForm(false)} + onSuccess={() => setShowChangePasswordForm(false)} + /> + ) : null} + + + ), + pageTitleProps: { id: titleId }, + rightSideItems: rightSideItems.reverse().map((item) => ( + + + {item.title} + + + + + + ), + description: {item.description}, + }, + ]} + compressed + /> + )), + }} + bottomBar={formChanges.count > 0 ? : null} + bottomBarProps={{ paddingSize: 'm' }} + restrictWidth={1000} + > +
+ {canChangeDetails ? ( + + + + } + description={ + + } + > + + + + } + labelAppend={} + fullWidth + > + + + + + + + } + labelAppend={} + fullWidth + > + + + + ) : null} + + {formik.values.data ? ( + + + + } + description={ + + } + > + + + {formik.values.avatarType === 'image' && + !formik.values.data.avatar.imageUrl ? ( + + ) : ( + + )} + + + + + + } + fullWidth + > + + ), + }, + { + id: 'image', + label: ( + + ), + iconType: 'image', + }, + ]} + onChange={(id: string) => formik.setFieldValue('avatarType', id)} + isFullWidth + /> + + + + + + {formik.values.avatarType === 'image' ? ( + + + + } + fullWidth + > + + ) : ( + + ) + } + onChange={createImageHandler((imageUrl) => { + formik.setFieldValue('data.avatar.imageUrl', imageUrl ?? ''); + })} + validate={{ + required: i18n.translate( + 'xpack.security.accountManagement.userProfile.imageUrlRequiredError', + { defaultMessage: 'Upload an image.' } + ), + }} + accept={IMAGE_FILE_TYPES.join(',')} + display="default" + fullWidth + /> + + ) : ( + + + + + + } + fullWidth + > + + + + + + + + } + labelAppend={ + !isReservedUser ? ( + + formik.setFieldValue('data.avatar.color', getRandomColor()) + } + size="xs" + flush="right" + style={{ height: euiTheme.base }} + > + + + ) : null + } + fullWidth + > + { + formik.setFieldValue('data.avatar.color', value); + }} + fullWidth + /> + + + + )} + + ) : null} + + {canChangePassword ? ( + + + + } + description={ + + } + > + + } + fullWidth + > + setShowChangePasswordForm(true)} + iconType="lock" + data-test-subj="openChangePasswordForm" + > + + + + + ) : null} +
+ +
+
+
+
+ ); +}; + +export function useUserProfileForm({ user, data }: UserProfileProps) { + const { services } = useKibana(); + const { userProfiles, users } = useApiClients(); + + const [initialValues, resetInitialValues] = useState({ + user: { + full_name: user.full_name || '', + email: user.email || '', + }, + data: data + ? { + avatar: { + initials: data.avatar?.initials || getUserAvatarInitials(user), + color: data.avatar?.color || getUserAvatarColor(user), + imageUrl: data.avatar?.imageUrl || '', + }, + } + : undefined, + avatarType: data?.avatar?.imageUrl ? 'image' : 'initials', + }); + + const [validateOnBlurOrChange, setValidateOnBlurOrChange] = useState(false); + const formik = useFormik({ + onSubmit: async (values) => { + const submitActions = []; + if (canUserChangeDetails(user, services.application.capabilities)) { + submitActions.push( + users.saveUser({ + username: user.username, + roles: user.roles, + enabled: user.enabled, + full_name: values.user.full_name, + email: values.user.email, + }) + ); + } + + // Update profile only if it's available for the current user. + if (values.data) { + submitActions.push( + userProfiles.update( + values.avatarType === 'image' + ? values.data + : { ...values.data, avatar: { ...values.data.avatar, imageUrl: null } } + ) + ); + } + + if (submitActions.length === 0) { + return; + } + + try { + await Promise.all(submitActions); + } catch (error) { + services.notifications.toasts.addError(error, { + title: i18n.translate('xpack.security.accountManagement.userProfile.submitErrorTitle', { + defaultMessage: "Couldn't update profile", + }), + }); + return; + } + + resetInitialValues(values); + services.notifications.toasts.addSuccess( + i18n.translate('xpack.security.accountManagement.userProfile.submitSuccessTitle', { + defaultMessage: 'Profile updated', + }) + ); + }, + initialValues, + enableReinitialize: true, + validateOnBlur: validateOnBlurOrChange, + validateOnChange: validateOnBlurOrChange, + }); + + // We perform _the first_ validation only when the user submits the form to make UX less annoying. But after the user + // submits the form, the validation model changes to on blur/change (as the user's mindset has changed from completing + // the form to correcting the form). + if (formik.submitCount > 0 && !validateOnBlurOrChange) { + setValidateOnBlurOrChange(true); + } else if (formik.submitCount === 0 && validateOnBlurOrChange) { + setValidateOnBlurOrChange(false); + } + + const customAvatarInitials = useRef( + !!data?.avatar?.initials && data.avatar?.initials !== getUserAvatarInitials(user) + ); + + useUpdateEffect(() => { + if (!customAvatarInitials.current) { + const defaultInitials = getUserAvatarInitials({ + username: user.username, + full_name: formik.values.user.full_name, + }); + formik.setFieldValue('data.avatar.initials', defaultInitials); + } + }, [formik.values.user.full_name]); + + useUpdateEffect(() => { + if (!customAvatarInitials.current && formik.values.data) { + const defaultInitials = getUserAvatarInitials({ + username: user.username, + full_name: formik.values.user.full_name, + }); + customAvatarInitials.current = formik.values.data.avatar.initials !== defaultInitials; + } + }, [formik.values.data?.avatar.initials]); + + return formik; +} + +export const SaveChangesBottomBar: FunctionComponent = () => { + const formik = useFormikContext(); + const { count } = useFormChangesContext(); + + return ( + + + + + + + + + + + + + + + + + + 0 && !formik.isValid} + color="success" + iconType="save" + fill + > + + + + + ); +}; diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_profile_api_client.test.ts b/x-pack/plugins/security/public/account_management/user_profile/user_profile_api_client.test.ts new file mode 100644 index 0000000000000..b9cd558eb08cd --- /dev/null +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile_api_client.test.ts @@ -0,0 +1,42 @@ +/* + * 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 { coreMock } from '@kbn/core/public/mocks'; + +import { UserProfileAPIClient } from './user_profile_api_client'; + +describe('UserProfileAPIClient', () => { + let coreStart: ReturnType; + let apiClient: UserProfileAPIClient; + beforeEach(() => { + coreStart = coreMock.createStart(); + coreStart.http.post.mockResolvedValue(undefined); + + apiClient = new UserProfileAPIClient(coreStart.http); + }); + + it('should get user profile without retrieving any user data', async () => { + await apiClient.get(); + expect(coreStart.http.get).toHaveBeenCalledWith('/internal/security/user_profile', { + query: { data: undefined }, + }); + }); + + it('should get user profile and user data', async () => { + await apiClient.get('*'); + expect(coreStart.http.get).toHaveBeenCalledWith('/internal/security/user_profile', { + query: { data: '*' }, + }); + }); + + it('should update user data', async () => { + await apiClient.update({ avatar: { imageUrl: 'avatar.png' } }); + expect(coreStart.http.post).toHaveBeenCalledWith('/internal/security/user_profile/_data', { + body: '{"avatar":{"imageUrl":"avatar.png"}}', + }); + }); +}); diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_profile_api_client.ts b/x-pack/plugins/security/public/account_management/user_profile/user_profile_api_client.ts new file mode 100644 index 0000000000000..7c358fd4d3513 --- /dev/null +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile_api_client.ts @@ -0,0 +1,46 @@ +/* + * 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 type { Observable } from 'rxjs'; +import { Subject } from 'rxjs'; + +import type { HttpStart } from '@kbn/core/public'; + +import type { AuthenticatedUserProfile, UserData } from '../../../common'; + +const USER_PROFILE_URL = '/internal/security/user_profile'; + +export class UserProfileAPIClient { + private readonly internalDataUpdates$: Subject = new Subject(); + + /** + * Emits event whenever user profile is changed by the user. + */ + public readonly dataUpdates$: Observable = this.internalDataUpdates$.asObservable(); + + constructor(private readonly http: HttpStart) {} + + /** + * Retrieves the user profile of the current user. + * @param dataPath By default `get()` returns user information, but does not return any user data. The optional "dataPath" parameter can be used to return personal data for this user. + */ + public get(dataPath?: string) { + return this.http.get>(USER_PROFILE_URL, { + query: { data: dataPath }, + }); + } + + /** + * Updates user profile data of the current user. + * @param data Application data to be written (merged with existing data). + */ + public update(data: T) { + return this.http.post(`${USER_PROFILE_URL}/_data`, { body: JSON.stringify(data) }).then(() => { + this.internalDataUpdates$.next(data); + }); + } +} diff --git a/x-pack/plugins/security/public/account_management/user_profile/utils.ts b/x-pack/plugins/security/public/account_management/user_profile/utils.ts new file mode 100644 index 0000000000000..fd15350288493 --- /dev/null +++ b/x-pack/plugins/security/public/account_management/user_profile/utils.ts @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const IMAGE_FILE_TYPES = ['image/svg+xml', 'image/jpeg', 'image/png', 'image/gif']; +export const MAX_IMAGE_SIZE = 64; + +export function readFile(data: File) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(data); + }); +} + +export function resizeImage(imageUrl: string, maxSize: number) { + return new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => { + if (image.width <= maxSize && image.height <= maxSize) { + return resolve(imageUrl); + } + + try { + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + if (context) { + if (image.width >= image.height) { + canvas.width = maxSize; + canvas.height = Math.floor((image.height * maxSize) / image.width); + } else { + canvas.height = maxSize; + canvas.width = Math.floor((image.width * maxSize) / image.height); + } + context.drawImage(image, 0, 0, canvas.width, canvas.height); + const resizedDataUrl = canvas.toDataURL(); + return resolve(resizedDataUrl); + } + } catch (error) { + return reject(error); + } + + return reject(); + }; + image.onerror = reject; + image.src = imageUrl; + }); +} + +export function createImageHandler(callback: (imageUrl: string | undefined) => void) { + return async (files: FileList | null) => { + if (!files || !files.length) { + callback(undefined); + return; + } + const file = files[0]; + if (IMAGE_FILE_TYPES.indexOf(file.type) !== -1) { + const imageUrl = await readFile(file); + const resizedImageUrl = await resizeImage(imageUrl, MAX_IMAGE_SIZE); + callback(resizedImageUrl); + } + }; +} + +/** + * Returns the hex representation of a random color (e.g `#F1B7E2`) + */ +export function getRandomColor() { + return '#' + String(Math.floor(Math.random() * 0xffffff).toString(16)).padStart(6, '0'); +} + +export const VALID_HEX_COLOR = /(^#[0-9A-F]{6}$)|(^#[0-9A-F]{3}$)/i; diff --git a/x-pack/plugins/security/public/components/api_clients_provider.ts b/x-pack/plugins/security/public/components/api_clients_provider.ts new file mode 100644 index 0000000000000..158a0855f172f --- /dev/null +++ b/x-pack/plugins/security/public/components/api_clients_provider.ts @@ -0,0 +1,23 @@ +/* + * 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 constate from 'constate'; + +import type { UserProfileAPIClient } from '../account_management'; +import type { UserAPIClient } from '../management'; + +export interface ApiClients { + userProfiles: UserProfileAPIClient; + users: UserAPIClient; +} + +const [ApiClientsProvider, useApiClients] = constate(({ userProfiles, users }: ApiClients) => ({ + userProfiles, + users, +})); + +export { ApiClientsProvider, useApiClients }; diff --git a/x-pack/plugins/security/public/components/form_changes.test.tsx b/x-pack/plugins/security/public/components/form_changes.test.tsx new file mode 100644 index 0000000000000..3223bb727ddfb --- /dev/null +++ b/x-pack/plugins/security/public/components/form_changes.test.tsx @@ -0,0 +1,68 @@ +/* + * 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 { act, renderHook } from '@testing-library/react-hooks'; + +import type { RevertFunction } from './form_changes'; +import { useFormChanges } from './form_changes'; + +describe('useFormChanges', () => { + it('should return correct contract', () => { + const { result } = renderHook(useFormChanges); + + expect(result.current).toEqual({ + count: 0, + report: expect.any(Function), + }); + }); + + it('should increase count when field changes', () => { + const { result } = renderHook(useFormChanges); + + expect(result.current.count).toEqual(0); + + act(() => { + result.current.report(false); + }); + + expect(result.current.count).toEqual(1); + }); + + it('should decrease count when field changes back', () => { + const { result } = renderHook(useFormChanges); + + expect(result.current.count).toEqual(0); + + let revert: RevertFunction | undefined; + act(() => { + revert = result.current.report(false); + }); + + expect(revert).not.toBeUndefined(); + expect(result.current.count).toEqual(1); + + act(() => { + revert!(); + }); + + expect(result.current.count).toEqual(0); + }); + + it('should not increase count when field remains unchanged', () => { + const { result } = renderHook(useFormChanges); + + expect(result.current.count).toEqual(0); + + let revert: RevertFunction | undefined; + act(() => { + revert = result.current.report(true); + }); + + expect(revert).toBeUndefined(); + expect(result.current.count).toEqual(0); + }); +}); diff --git a/x-pack/plugins/security/public/components/form_changes.tsx b/x-pack/plugins/security/public/components/form_changes.tsx new file mode 100644 index 0000000000000..c2724e1d193fe --- /dev/null +++ b/x-pack/plugins/security/public/components/form_changes.tsx @@ -0,0 +1,74 @@ +/* + * 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 { createContext, useContext, useState } from 'react'; + +export interface FormChangesProps { + /** + * Number of fields rendered on the page that have changed. + */ + count: number; + + /** + * Callback function used by a form field to indicate whether its current value is different to its initial value. + * + * @example + * ``` + * const { report } = useFormChangesContext(); + * const isEqual = formik.values.email === formik.initialValues.email; + * + * useEffect(() => report(isEqual), [isEqual]); + * ``` + */ + report: ReportFunction; +} + +export type ReportFunction = (isEqual: boolean) => undefined | RevertFunction; +export type RevertFunction = () => void; + +/** + * Custom React hook that allows tracking changes within a form. + * + * @example + * ``` + * const { count } = useFormChanges(); // Form has {count} unsaved changes + * ``` + */ +export const useFormChanges = (): FormChangesProps => { + const [count, setCount] = useState(0); + + return { + count, + report: (isEqual) => { + if (!isEqual) { + setCount((c) => c + 1); + return () => setCount((c) => c - 1); + } + }, + }; +}; + +const FormChangesContext = createContext(undefined); + +export const FormChangesProvider = FormChangesContext.Provider; + +/** + * Custom React hook that returns all @see FormChangesProps state from context. + * + * @throws Error if called within a component that isn't a child of a `` component. + */ +export function useFormChangesContext() { + const value = useContext(FormChangesContext); + + if (!value) { + throw new Error( + 'FormChanges context is undefined, please verify you are calling useFormChangesContext() as child of a component.' + ); + } + + return value; +} diff --git a/x-pack/plugins/security/public/components/form_field.test.tsx b/x-pack/plugins/security/public/components/form_field.test.tsx new file mode 100644 index 0000000000000..0abb7c82a833e --- /dev/null +++ b/x-pack/plugins/security/public/components/form_field.test.tsx @@ -0,0 +1,178 @@ +/* + * 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 { EuiFieldNumber, EuiFieldText } from '@elastic/eui'; +import { mount } from 'enzyme'; +import { Formik } from 'formik'; +import React from 'react'; + +import { createFieldValidator, FormField } from './form_field'; + +const onSubmit = jest.fn(); + +describe('FormField', () => { + it('should render text field by default', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.exists(EuiFieldText)).toEqual(true); + }); + + it('should render custom component if specified', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.exists(EuiFieldNumber)).toEqual(true); + }); + + it('should render component with correct field props and event handlers', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find(EuiFieldText).props()).toEqual( + expect.objectContaining({ + name: 'email', + value: 'mail@example.com', + onChange: expect.any(Function), + onBlur: expect.any(Function), + }) + ); + }); + + it('should mark as invalid if field has errors and has been touched', () => { + const assertions = [ + { error: 'Error', touched: true, isInvalid: true }, + { error: 'Error', touched: false, isInvalid: false }, + { error: undefined, touched: true, isInvalid: false }, + ]; + assertions.forEach(({ error, touched, isInvalid }) => { + const wrapper = mount( + + + + ); + expect(wrapper.find(EuiFieldText).props()).toEqual(expect.objectContaining({ isInvalid })); + }); + }); +}); + +describe('createFieldValidator', () => { + it('should validate required field', () => { + const validate = createFieldValidator({ + required: 'Error', + }); + + expect(validate(undefined)).toEqual('Error'); + expect(validate(null)).toEqual('Error'); + expect(validate('')).toEqual('Error'); + + expect(validate(0)).toBeUndefined(); + expect(validate(1)).toBeUndefined(); + expect(validate('a')).toBeUndefined(); + expect(validate({})).toBeUndefined(); + expect(validate([])).toBeUndefined(); + }); + + it('should validate field pattern', () => { + const validate = createFieldValidator({ + pattern: { + value: /^[a-z]{2}$/, + message: 'Error', + }, + }); + + expect(validate(undefined)).toEqual('Error'); + expect(validate(null)).toEqual('Error'); + expect(validate(0)).toEqual('Error'); + expect(validate(1)).toEqual('Error'); + expect(validate('a')).toEqual('Error'); + + expect(validate('ab')).toBeUndefined(); + }); + + it('should validate minimum length ', () => { + const validate = createFieldValidator({ + minLength: { + value: 2, + message: 'Error', + }, + }); + + expect(validate(undefined)).toEqual('Error'); + expect(validate(null)).toEqual('Error'); + expect(validate('a')).toEqual('Error'); + expect(validate([0])).toEqual('Error'); + + expect(validate('ab')).toBeUndefined(); + expect(validate([0, 1])).toBeUndefined(); + }); + + it('should validate maximum length', () => { + const validate = createFieldValidator({ + maxLength: { + value: 2, + message: 'Error', + }, + }); + + expect(validate('abc')).toEqual('Error'); + expect(validate([0, 1, 3])).toEqual('Error'); + + expect(validate(undefined)).toBeUndefined(); + expect(validate(null)).toBeUndefined(); + expect(validate('ab')).toBeUndefined(); + expect(validate([0, 1])).toBeUndefined(); + }); + + it('should validate minimum value', () => { + const validate = createFieldValidator({ + min: { + value: 2, + message: 'Error', + }, + }); + + expect(validate(undefined)).toEqual('Error'); + expect(validate(null)).toEqual('Error'); + expect(validate(1)).toEqual('Error'); + expect(validate('1')).toEqual('Error'); + + expect(validate(2)).toBeUndefined(); + expect(validate('2')).toBeUndefined(); + }); + + it('should validate maximum value', () => { + const validate = createFieldValidator({ + max: { + value: 2, + message: 'Error', + }, + }); + + expect(validate(undefined)).toEqual('Error'); + expect(validate(3)).toEqual('Error'); + expect(validate('3')).toEqual('Error'); + + expect(validate(null)).toBeUndefined(); + expect(validate(2)).toBeUndefined(); + expect(validate('2')).toBeUndefined(); + }); +}); diff --git a/x-pack/plugins/security/public/components/form_field.tsx b/x-pack/plugins/security/public/components/form_field.tsx new file mode 100644 index 0000000000000..6e223f067c99b --- /dev/null +++ b/x-pack/plugins/security/public/components/form_field.tsx @@ -0,0 +1,125 @@ +/* + * 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 { EuiFieldText } from '@elastic/eui'; +import { useField } from 'formik'; +import type { FieldValidator } from 'formik'; +import type { ComponentPropsWithoutRef, ElementType } from 'react'; +import React from 'react'; + +export interface FormFieldProps { + as?: T; + name: string; + validate?: FieldValidator | ValidateOptions; +} + +/** + * Polymorphic component that renders a form field with all state required for inline validation. + * + * @example Text field with validation rule: + * ```typescript + * + * + * + * ``` + * + * @example Color picker using non-standard value prop and change handler: + * ```typescript + * + * formik.setFieldValue('color', value)} + * /> + * + * ``` + * + * @throws Error if not a child of a `` component. + */ +export function FormField({ + as, + validate, + onBlur, + ...rest +}: FormFieldProps & Omit, keyof FormFieldProps>) { + const Component = as || EuiFieldText; + + const [field, meta, helpers] = useField({ + name: rest.name, + validate: typeof validate === 'object' ? createFieldValidator(validate) : validate, + }); + + return ( + { + helpers.setTouched(true); // Marking as touched manually here since some EUI components don't pass on the native blur event which is required by `field.onBlur()`. + onBlur?.(event); + }} + /> + ); +} + +export interface ValidateOptions { + required?: string; + pattern?: { + value: RegExp; + message: string; + }; + minLength?: { + value: number; + message: string; + }; + maxLength?: { + value: number; + message: string; + }; + min?: { + value: number; + message: string; + }; + max?: { + value: number; + message: string; + }; +} + +export function createFieldValidator(options: ValidateOptions): FieldValidator { + return (value: any) => { + if (options.required && typeof value !== 'number' && !value) { + return options.required; + } + if (options.pattern && !options.pattern.value.test(value)) { + return options.pattern.message; + } + if ( + options.minLength && + (!value || + ((typeof value === 'object' || typeof value === 'string') && + value.length < options.minLength.value)) + ) { + return options.minLength.message; + } + if ( + options.maxLength && + value && + (typeof value === 'object' || typeof value === 'string') && + value.length > options.maxLength.value + ) { + return options.maxLength.message; + } + if (options.min && (isNaN(value) || value < options.min.value)) { + return options.min.message; + } + if (options.max && (isNaN(value) || value > options.max.value)) { + return options.max.message; + } + }; +} diff --git a/x-pack/plugins/security/public/components/form_label.test.tsx b/x-pack/plugins/security/public/components/form_label.test.tsx new file mode 100644 index 0000000000000..3f3ef72101e7a --- /dev/null +++ b/x-pack/plugins/security/public/components/form_label.test.tsx @@ -0,0 +1,44 @@ +/* + * 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 { act, render } from '@testing-library/react'; +import type { FormikContextType } from 'formik'; +import { Formik, FormikConsumer } from 'formik'; +import React from 'react'; + +import { FormChangesProvider } from './form_changes'; +import { FormLabel } from './form_label'; + +describe('FormLabel', () => { + it('should report form changes', () => { + const onSubmit = jest.fn(); + const report = jest.fn(); + + let formik: FormikContextType; + render( + + + + + {(value) => { + formik = value; + return null; + }} + + + + ); + + expect(report).toHaveBeenLastCalledWith(true); + + act(() => { + formik.setFieldValue('email', 'mail@example.com'); + }); + + expect(report).toHaveBeenLastCalledWith(false); + }); +}); diff --git a/x-pack/plugins/security/public/components/form_label.tsx b/x-pack/plugins/security/public/components/form_label.tsx new file mode 100644 index 0000000000000..724dfa9902b02 --- /dev/null +++ b/x-pack/plugins/security/public/components/form_label.tsx @@ -0,0 +1,58 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem, EuiIcon } from '@elastic/eui'; +import { useFormikContext } from 'formik'; +import type { FunctionComponent } from 'react'; +import React, { useEffect } from 'react'; + +import { useFormChangesContext } from './form_changes'; + +export interface FormLabelProps { + /** + * Name of target form field. + */ + for: string; +} + +/** + * Component that visually indicates whether a field value has changed. + * + * @example Renders a dot next to "Email" label when field value changes. + * ```typescript + * + * + * Email}> + * + * + * + * + * ``` + * + * @throws Error if not a child of a `` component. + * @throws Error if not a child of a `` component. + */ +export const FormLabel: FunctionComponent = (props) => { + const formik = useFormikContext(); + const { report } = useFormChangesContext(); + + const meta = formik.getFieldMeta(props.for); + const isEqual = meta.value === meta.initialValue; + + useEffect(() => report(isEqual), [isEqual]); // eslint-disable-line react-hooks/exhaustive-deps + + return ( + + {props.children} + {!isEqual ? ( + + + + ) : undefined} + + ); +}; diff --git a/x-pack/plugins/security/public/components/form_row.test.tsx b/x-pack/plugins/security/public/components/form_row.test.tsx new file mode 100644 index 0000000000000..962bf5e79f974 --- /dev/null +++ b/x-pack/plugins/security/public/components/form_row.test.tsx @@ -0,0 +1,44 @@ +/* + * 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 { EuiFormRow } from '@elastic/eui'; +import { mount } from 'enzyme'; +import { Formik } from 'formik'; +import React from 'react'; + +import { FormRow } from './form_row'; + +describe('FormRow', () => { + it('should render form row with correct error states', () => { + const assertions = [ + { error: 'Error', touched: true, isInvalid: true }, + { error: 'Error', touched: false, isInvalid: false }, + { error: undefined, touched: true, isInvalid: false }, + ]; + assertions.forEach(({ error, touched, isInvalid }) => { + const wrapper = mount( + + + + + + ); + + expect(wrapper.find(EuiFormRow).props()).toEqual( + expect.objectContaining({ + error, + isInvalid, + }) + ); + }); + }); +}); diff --git a/x-pack/plugins/security/public/components/form_row.tsx b/x-pack/plugins/security/public/components/form_row.tsx new file mode 100644 index 0000000000000..a6f4e475c3e19 --- /dev/null +++ b/x-pack/plugins/security/public/components/form_row.tsx @@ -0,0 +1,66 @@ +/* + * 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 { EuiFormRow, EuiText } from '@elastic/eui'; +import type { EuiFormRowProps } from '@elastic/eui'; +import { useFormikContext } from 'formik'; +import type { FunctionComponent } from 'react'; +import React, { Children } from 'react'; + +import { FormattedMessage } from '@kbn/i18n-react'; + +export interface FormRowProps { + /** + * Optional name of form field. + * + * If not provided the name will be inferred from its child element. + */ + name?: string; +} + +/** + * Component that renders a form row with all error states for inline validation. + * + * @example + * ```typescript + * + * + * + * + * + * ``` + * + * @throws Error if not a child of a `` component. + * @throws Error if `name` prop is not set and can't be inferred from its child element. + */ +export const FormRow: FunctionComponent = (props) => { + const formik = useFormikContext(); + const child = Children.only(props.children); + const name = props.name ?? child.props.name; + + if (!name) { + throw new Error( + 'name prop is undefined, please verify you are either rendering itself or its child with a name prop.' + ); + } + + const meta = formik.getFieldMeta(name); + + return ( + + {child} + + ); +}; + +export const OptionalText: FunctionComponent = () => { + return ( + + + + ); +}; diff --git a/x-pack/plugins/security/public/components/index.ts b/x-pack/plugins/security/public/components/index.ts new file mode 100644 index 0000000000000..9f3eaacb61f49 --- /dev/null +++ b/x-pack/plugins/security/public/components/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { ApiClientsProvider, useApiClients } from './api_clients_provider'; +export type { ApiClients } from './api_clients_provider'; +export { + AuthenticationProvider, + useAuthentication, + useUserProfile, + useCurrentUser, +} from './use_current_user'; diff --git a/x-pack/plugins/security/public/components/use_current_user.ts b/x-pack/plugins/security/public/components/use_current_user.ts index 103952d7d34ef..601cb42213724 100644 --- a/x-pack/plugins/security/public/components/use_current_user.ts +++ b/x-pack/plugins/security/public/components/use_current_user.ts @@ -7,7 +7,10 @@ import constate from 'constate'; import useAsync from 'react-use/lib/useAsync'; +import useObservable from 'react-use/lib/useObservable'; +import { useApiClients } from '.'; +import type { UserData } from '../../common'; import type { AuthenticationServiceSetup } from '../authentication'; export interface AuthenticationProviderProps { @@ -24,3 +27,9 @@ export function useCurrentUser() { const authc = useAuthentication(); return useAsync(authc.getCurrentUser, [authc]); } + +export function useUserProfile(dataPath?: string) { + const { userProfiles } = useApiClients(); + const dataUpdateState = useObservable(userProfiles.dataUpdates$); + return useAsync(() => userProfiles.get(dataPath), [userProfiles, dataUpdateState]); +} diff --git a/x-pack/plugins/security/public/index.ts b/x-pack/plugins/security/public/index.ts index 158af4f2de8d2..3940361ad72bd 100644 --- a/x-pack/plugins/security/public/index.ts +++ b/x-pack/plugins/security/public/index.ts @@ -18,9 +18,8 @@ import { SecurityPlugin } from './plugin'; export type { SecurityPluginSetup, SecurityPluginStart }; export type { AuthenticatedUser } from '../common/model'; export type { SecurityLicense, SecurityLicenseFeatures } from '../common/licensing'; +export type { UiApi, ChangePasswordProps, PersonalInfoProps } from './ui_api'; export type { UserMenuLink, SecurityNavControlServiceStart } from './nav_control'; -export type { UiApi } from './ui_api'; -export type { PersonalInfoProps, ChangePasswordProps } from './account_management'; export type { AuthenticationServiceStart, AuthenticationServiceSetup } from './authentication'; diff --git a/x-pack/plugins/security/public/management/users/edit_user/change_password_flyout.tsx b/x-pack/plugins/security/public/management/users/edit_user/change_password_flyout.tsx deleted file mode 100644 index 53c298e270112..0000000000000 --- a/x-pack/plugins/security/public/management/users/edit_user/change_password_flyout.tsx +++ /dev/null @@ -1,297 +0,0 @@ -/* - * 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 { - EuiCallOut, - EuiFieldPassword, - EuiFlexGroup, - EuiFlexItem, - EuiForm, - EuiFormRow, - EuiIcon, - EuiLoadingContent, - EuiSpacer, - EuiText, -} from '@elastic/eui'; -import type { FunctionComponent } from 'react'; -import React from 'react'; - -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { useKibana } from '@kbn/kibana-react-plugin/public'; - -import { FormFlyout } from '../../../components/form_flyout'; -import { useCurrentUser } from '../../../components/use_current_user'; -import type { ValidationErrors } from '../../../components/use_form'; -import { useForm } from '../../../components/use_form'; -import { useInitialFocus } from '../../../components/use_initial_focus'; -import { UserAPIClient } from '../user_api_client'; - -export interface ChangePasswordFormValues { - current_password?: string; - password: string; - confirm_password: string; -} - -export interface ChangePasswordFlyoutProps { - username: string; - defaultValues?: ChangePasswordFormValues; - onCancel(): void; - onSuccess?(): void; -} - -export const validateChangePasswordForm = ( - values: ChangePasswordFormValues, - isCurrentUser: boolean -) => { - const errors: ValidationErrors = {}; - - if (isCurrentUser) { - if (!values.current_password) { - errors.current_password = i18n.translate( - 'xpack.security.management.users.changePasswordFlyout.currentPasswordRequiredError', - { - defaultMessage: 'Enter your current password.', - } - ); - } - } - - if (!values.password) { - errors.password = i18n.translate( - 'xpack.security.management.users.changePasswordFlyout.passwordRequiredError', - { - defaultMessage: 'Enter a new password.', - } - ); - } else if (values.password.length < 6) { - errors.password = i18n.translate( - 'xpack.security.management.users.changePasswordFlyout.passwordInvalidError', - { - defaultMessage: 'Password must be at least 6 characters.', - } - ); - } else if (values.password !== values.confirm_password) { - errors.confirm_password = i18n.translate( - 'xpack.security.management.users.changePasswordFlyout.confirmPasswordInvalidError', - { - defaultMessage: 'Passwords do not match.', - } - ); - } - - return errors; -}; - -export const ChangePasswordFlyout: FunctionComponent = ({ - username, - defaultValues = { - current_password: '', - password: '', - confirm_password: '', - }, - onSuccess, - onCancel, -}) => { - const { services } = useKibana(); - const { value: currentUser, loading: isLoading } = useCurrentUser(); - const isCurrentUser = currentUser?.username === username; - const isSystemUser = username === 'kibana' || username === 'kibana_system'; - - const [form, eventHandlers] = useForm({ - onSubmit: async (values) => { - try { - await new UserAPIClient(services.http!).changePassword( - username, - values.password, - values.current_password - ); - services.notifications!.toasts.addSuccess( - i18n.translate('xpack.security.management.users.changePasswordFlyout.successMessage', { - defaultMessage: "Password changed for '{username}'.", - values: { username }, - }) - ); - onSuccess?.(); - } catch (error) { - if ((error as any).body?.message === 'security_exception') { - form.setError( - 'current_password', - i18n.translate( - 'xpack.security.management.users.changePasswordFlyout.currentPasswordInvalidError', - { - defaultMessage: 'Invalid password.', - } - ) - ); - } else { - services.notifications!.toasts.addDanger({ - title: i18n.translate( - 'xpack.security.management.users.changePasswordFlyout.errorMessage', - { - defaultMessage: 'Could not change password', - } - ), - text: (error as any).body?.message || error.message, - }); - throw error; - } - } - }, - validate: async (values) => validateChangePasswordForm(values, isCurrentUser), - defaultValues, - }); - - const firstFieldRef = useInitialFocus([isLoading]); - - return ( - - {isLoading ? ( - - ) : ( - - {isSystemUser ? ( - <> - -

- -

-

- -

-
- - - ) : undefined} - - - - - - - - - {username} - - - - - - {isCurrentUser ? ( - - - - ) : null} - - - - - - - - - {/* Hidden submit button is required for enter key to trigger form submission */} - -
- )} -
- ); -}; diff --git a/x-pack/plugins/security/public/management/users/edit_user/change_password_modal.tsx b/x-pack/plugins/security/public/management/users/edit_user/change_password_modal.tsx new file mode 100644 index 0000000000000..8b8ca9f393917 --- /dev/null +++ b/x-pack/plugins/security/public/management/users/edit_user/change_password_modal.tsx @@ -0,0 +1,304 @@ +/* + * 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 { + EuiButton, + EuiButtonEmpty, + EuiCallOut, + EuiFieldPassword, + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiFormRow, + EuiIcon, + EuiLoadingContent, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiSpacer, + EuiText, + useGeneratedHtmlId, +} from '@elastic/eui'; +import type { FunctionComponent } from 'react'; +import React from 'react'; + +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { euiThemeVars } from '@kbn/ui-theme'; + +import { useCurrentUser } from '../../../components/use_current_user'; +import type { ValidationErrors } from '../../../components/use_form'; +import { useForm } from '../../../components/use_form'; +import { useInitialFocus } from '../../../components/use_initial_focus'; +import { UserAPIClient } from '../user_api_client'; + +export interface ChangePasswordFormValues { + current_password?: string; + password: string; + confirm_password: string; +} + +export interface ChangePasswordModalProps { + username: string; + defaultValues?: ChangePasswordFormValues; + onCancel(): void; + onSuccess?(): void; +} + +export const validateChangePasswordForm = ( + values: ChangePasswordFormValues, + isCurrentUser: boolean +) => { + const errors: ValidationErrors = {}; + + if (isCurrentUser) { + if (!values.current_password) { + errors.current_password = i18n.translate( + 'xpack.security.management.users.changePasswordForm.currentPasswordRequiredError', + { defaultMessage: 'Enter your current password.' } + ); + } + } + + if (!values.password) { + errors.password = i18n.translate( + 'xpack.security.management.users.changePasswordForm.passwordRequiredError', + { defaultMessage: 'Enter a new password.' } + ); + } else if (values.password.length < 6) { + errors.password = i18n.translate( + 'xpack.security.management.users.changePasswordForm.passwordInvalidError', + { defaultMessage: 'Enter at least 6 characters.' } + ); + } else if (values.password !== values.confirm_password) { + errors.confirm_password = i18n.translate( + 'xpack.security.management.users.changePasswordForm.confirmPasswordInvalidError', + { defaultMessage: 'Passwords do not match.' } + ); + } + + return errors; +}; + +export const ChangePasswordModal: FunctionComponent = ({ + username, + defaultValues = { + current_password: '', + password: '', + confirm_password: '', + }, + onSuccess, + onCancel, +}) => { + const { services } = useKibana(); + const { value: currentUser, loading: isLoading } = useCurrentUser(); + const isCurrentUser = currentUser?.username === username; + const isSystemUser = username === 'kibana' || username === 'kibana_system'; + + const [form, eventHandlers] = useForm({ + onSubmit: async (values) => { + try { + await new UserAPIClient(services.http!).changePassword( + username, + values.password, + values.current_password + ); + services.notifications!.toasts.addSuccess( + i18n.translate('xpack.security.management.users.changePasswordForm.successMessage', { + defaultMessage: 'Password successfully changed.', + }) + ); + onSuccess?.(); + } catch (error) { + if ((error as any).body?.statusCode === 403) { + form.setError( + 'current_password', + i18n.translate( + 'xpack.security.management.users.changePasswordForm.currentPasswordInvalidError', + { defaultMessage: 'Invalid password.' } + ) + ); + } else { + services.notifications!.toasts.addDanger({ + title: i18n.translate( + 'xpack.security.management.users.changePasswordForm.errorMessage', + { defaultMessage: 'Could not change password' } + ), + text: (error as any).body?.message || error.message, + }); + throw error; + } + } + }, + validate: async (values) => validateChangePasswordForm(values, isCurrentUser), + defaultValues, + }); + + const firstFieldRef = useInitialFocus([isLoading]); + const modalFormId = useGeneratedHtmlId({ prefix: 'modalForm' }); + + return ( + + + + + + + + {isLoading ? ( + + ) : ( + + {isSystemUser ? ( + <> + +

+ +

+

+ +

+
+ + + ) : undefined} + + {isCurrentUser ? ( + <> + + + + + ) : ( + + + + + + + + {username} + + + + + )} + + + + + + + +
+ )} +
+ + + + + + + + +
+ ); +}; diff --git a/x-pack/plugins/security/public/management/users/edit_user/change_password_flyout.test.tsx b/x-pack/plugins/security/public/management/users/edit_user/change_password_model.test.tsx similarity index 90% rename from x-pack/plugins/security/public/management/users/edit_user/change_password_flyout.test.tsx rename to x-pack/plugins/security/public/management/users/edit_user/change_password_model.test.tsx index b0b8ca2030fa0..f040803ace705 100644 --- a/x-pack/plugins/security/public/management/users/edit_user/change_password_flyout.test.tsx +++ b/x-pack/plugins/security/public/management/users/edit_user/change_password_model.test.tsx @@ -5,10 +5,10 @@ * 2.0. */ -import type { ChangePasswordFormValues } from './change_password_flyout'; -import { validateChangePasswordForm } from './change_password_flyout'; +import type { ChangePasswordFormValues } from './change_password_modal'; +import { validateChangePasswordForm } from './change_password_modal'; -describe('ChangePasswordFlyout', () => { +describe('ChangePasswordModal', () => { describe('#validateChangePasswordForm', () => { describe('for current user', () => { it('should show an error when it is current user with no current password', () => { @@ -41,11 +41,11 @@ describe('ChangePasswordFlyout', () => { it('should show errors when the new password is not at least 6 characters', () => { expect(validateChangePasswordForm({ password: '12345', confirm_password: '12345' }, true)) .toMatchInlineSnapshot(` - Object { - "current_password": "Enter your current password.", - "password": "Password must be at least 6 characters.", - } - `); + Object { + "current_password": "Enter your current password.", + "password": "Enter at least 6 characters.", + } + `); }); it('should show errors when new password does not match confirmation password', () => { @@ -100,7 +100,7 @@ describe('ChangePasswordFlyout', () => { expect(validateChangePasswordForm({ password: '1234', confirm_password: '1234' }, false)) .toMatchInlineSnapshot(` Object { - "password": "Password must be at least 6 characters.", + "password": "Enter at least 6 characters.", } `); }); diff --git a/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.tsx b/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.tsx index a64ca3aca99eb..231ef70fed5bf 100644 --- a/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.tsx +++ b/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.tsx @@ -32,7 +32,7 @@ import { useKibana } from '@kbn/kibana-react-plugin/public'; import { getUserDisplayName } from '../../../../common/model'; import { UserAPIClient } from '../user_api_client'; import { isUserDeprecated, isUserReserved } from '../user_utils'; -import { ChangePasswordFlyout } from './change_password_flyout'; +import { ChangePasswordModal } from './change_password_modal'; import { ConfirmDeleteUsers } from './confirm_delete_users'; import { ConfirmDisableUsers } from './confirm_disable_users'; import { ConfirmEnableUsers } from './confirm_enable_users'; @@ -157,7 +157,7 @@ export const EditUserPage: FunctionComponent = ({ username }) /> {action === 'changePassword' ? ( - setAction('none')} onSuccess={() => setAction('none')} diff --git a/x-pack/plugins/security/public/management/users/edit_user/user_form.tsx b/x-pack/plugins/security/public/management/users/edit_user/user_form.tsx index d1134d0959a16..83cf8dae89416 100644 --- a/x-pack/plugins/security/public/management/users/edit_user/user_form.tsx +++ b/x-pack/plugins/security/public/management/users/edit_user/user_form.tsx @@ -255,7 +255,7 @@ export const UserForm: FunctionComponent = ({ !isNewUser && !isReservedUser ? i18n.translate( 'xpack.security.management.users.userForm.changingUserNameAfterCreationDescription', - { defaultMessage: `Username can't be changed once created.` } + { defaultMessage: 'User name cannot be changed after account creation.' } ) : undefined } diff --git a/x-pack/plugins/security/public/nav_control/nav_control_component.scss b/x-pack/plugins/security/public/nav_control/nav_control_component.scss deleted file mode 100644 index a3e04b08cfac2..0000000000000 --- a/x-pack/plugins/security/public/nav_control/nav_control_component.scss +++ /dev/null @@ -1,11 +0,0 @@ -.chrNavControl__userMenu { - .euiContextMenuPanelTitle { - // Uppercased by default, override to match actual username - text-transform: none; - } - - .euiContextMenuItem { - // Temp fix for EUI issue https://github.com/elastic/eui/issues/3092 - line-height: normal; - } -} \ No newline at end of file diff --git a/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx b/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx index be74cca3d2671..24d4594091d5f 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx +++ b/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx @@ -5,28 +5,57 @@ * 2.0. */ -import { EuiContextMenuItem, EuiHeaderSectionItemButton, EuiPopover } from '@elastic/eui'; +import { EuiContextMenu } from '@elastic/eui'; +import { shallow } from 'enzyme'; +import type { ReactElement } from 'react'; import React from 'react'; +import { act } from 'react-dom/test-utils'; +import useObservable from 'react-use/lib/useObservable'; import { BehaviorSubject } from 'rxjs'; -import { findTestSubject, mountWithIntl, nextTick, shallowWithIntl } from '@kbn/test-jest-helpers'; - -import type { AuthenticatedUser } from '../../common/model'; import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock'; +import { userProfileMock } from '../../common/model/user_profile.mock'; +import * as UseCurrentUserImports from '../components/use_current_user'; import { SecurityNavControl } from './nav_control_component'; +jest.mock('../components/use_current_user'); +jest.mock('react-use/lib/useObservable'); + +const useObservableMock = useObservable as jest.Mock; +const useUserProfileMock = jest.spyOn(UseCurrentUserImports, 'useUserProfile'); +const useCurrentUserMock = jest.spyOn(UseCurrentUserImports, 'useCurrentUser'); + +const userProfile = userProfileMock.create(); +const userMenuLinks$ = new BehaviorSubject([]); + describe('SecurityNavControl', () => { - it(`renders a loading spinner when the user promise hasn't resolved yet.`, async () => { - const props = { - user: new Promise(() => mockAuthenticatedUser()), - editProfileUrl: '', - logoutUrl: '', - userMenuLinks$: new BehaviorSubject([]), - }; - - const wrapper = shallowWithIntl(); - const { button } = wrapper.find(EuiPopover).props(); - expect(button).toMatchInlineSnapshot(` + beforeEach(() => { + useUserProfileMock.mockReset(); + useUserProfileMock.mockReturnValue({ + loading: false, + value: userProfile, + }); + + useCurrentUserMock.mockReset(); + useCurrentUserMock.mockReturnValue({ + loading: false, + value: mockAuthenticatedUser(), + }); + + useObservableMock.mockReset(); + useObservableMock.mockImplementation( + (observable: BehaviorSubject, initialValue = {}) => observable.value ?? initialValue + ); + }); + + it('should render an avatar when user profile has loaded', async () => { + const wrapper = shallow( + + ); + + expect(useUserProfileMock).toHaveBeenCalledTimes(1); + expect(useCurrentUserMock).toHaveBeenCalledTimes(1); + expect(wrapper.prop('button')).toMatchInlineSnapshot(` { aria-label="Account menu" data-test-subj="userMenuButton" onClick={[Function]} + style={ + Object { + "lineHeight": "normal", + } + } > - `); }); - it(`renders an avatar after the user promise resolves.`, async () => { - const props = { - user: Promise.resolve(mockAuthenticatedUser({ full_name: 'foo' })), - editProfileUrl: '', - logoutUrl: '', - userMenuLinks$: new BehaviorSubject([]), - }; - - const wrapper = shallowWithIntl(); - await nextTick(); - wrapper.update(); - const { button } = wrapper.find(EuiPopover).props(); - expect(button).toMatchInlineSnapshot(` + it('should render a spinner while loading', () => { + useUserProfileMock.mockReturnValue({ + loading: true, + }); + useCurrentUserMock.mockReturnValue({ + loading: true, + }); + + const wrapper = shallow( + + ); + + expect(useUserProfileMock).toHaveBeenCalledTimes(1); + expect(useCurrentUserMock).toHaveBeenCalledTimes(1); + expect(wrapper.prop('button')).toMatchInlineSnapshot(` { aria-label="Account menu" data-test-subj="userMenuButton" onClick={[Function]} + style={ + Object { + "lineHeight": "normal", + } + } > - `); }); - it(`doesn't render the popover when the user hasn't been loaded yet`, async () => { - const props = { - user: Promise.resolve(mockAuthenticatedUser({ full_name: 'foo' })), - editProfileUrl: '', - logoutUrl: '', - userMenuLinks$: new BehaviorSubject([]), - }; + it('should open popover when avatar is clicked', async () => { + const wrapper = shallow( + + ); - const wrapper = mountWithIntl(); - // not awaiting the user promise + act(() => { + wrapper.prop('button').props.onClick(); + wrapper.update(); + }); - expect(findTestSubject(wrapper, 'userMenu')).toHaveLength(0); - expect(findTestSubject(wrapper, 'profileLink')).toHaveLength(0); - expect(findTestSubject(wrapper, 'logoutLink')).toHaveLength(0); - - wrapper.find(EuiHeaderSectionItemButton).simulate('click'); - - expect(findTestSubject(wrapper, 'userMenu')).toHaveLength(0); - expect(findTestSubject(wrapper, 'profileLink')).toHaveLength(0); - expect(findTestSubject(wrapper, 'logoutLink')).toHaveLength(0); + expect(wrapper.prop('isOpen')).toEqual(true); }); - it('renders a popover when the avatar is clicked.', async () => { - const props = { - user: Promise.resolve(mockAuthenticatedUser({ full_name: 'foo' })), - editProfileUrl: '', - logoutUrl: '', - userMenuLinks$: new BehaviorSubject([]), - }; - - const wrapper = mountWithIntl(); - await nextTick(); - wrapper.update(); + it('should not open popover while loading', () => { + useUserProfileMock.mockReturnValue({ + loading: true, + }); + useCurrentUserMock.mockReturnValue({ + loading: true, + }); - expect(findTestSubject(wrapper, 'userMenu')).toHaveLength(0); - expect(findTestSubject(wrapper, 'profileLink')).toHaveLength(0); - expect(findTestSubject(wrapper, 'logoutLink')).toHaveLength(0); + const wrapper = shallow( + + ); - wrapper.find(EuiHeaderSectionItemButton).simulate('click'); + act(() => { + wrapper.prop('button').props.onClick(); + wrapper.update(); + }); - expect(findTestSubject(wrapper, 'userMenu')).toHaveLength(1); - expect(findTestSubject(wrapper, 'profileLink')).toHaveLength(1); - expect(findTestSubject(wrapper, 'logoutLink')).toHaveLength(1); + expect(wrapper.prop('isOpen')).toEqual(false); }); - it('renders a popover with additional user menu links registered by other plugins', async () => { - const props = { - user: Promise.resolve(mockAuthenticatedUser({ full_name: 'foo' })), - editProfileUrl: '', - logoutUrl: '', - userMenuLinks$: new BehaviorSubject([ - { label: 'link1', href: 'path-to-link-1', iconType: 'empty', order: 1 }, - { label: 'link2', href: 'path-to-link-2', iconType: 'empty', order: 2 }, - { label: 'link3', href: 'path-to-link-3', iconType: 'empty', order: 3 }, - ]), - }; - - const wrapper = mountWithIntl(); - await nextTick(); - wrapper.update(); - - expect(findTestSubject(wrapper, 'userMenu')).toHaveLength(0); - expect(findTestSubject(wrapper, 'profileLink')).toHaveLength(0); - expect(findTestSubject(wrapper, 'userMenuLink__link1')).toHaveLength(0); - expect(findTestSubject(wrapper, 'userMenuLink__link2')).toHaveLength(0); - expect(findTestSubject(wrapper, 'userMenuLink__link3')).toHaveLength(0); - expect(findTestSubject(wrapper, 'logoutLink')).toHaveLength(0); - - wrapper.find(EuiHeaderSectionItemButton).simulate('click'); - - expect(findTestSubject(wrapper, 'userMenu')).toHaveLength(1); - expect(findTestSubject(wrapper, 'profileLink')).toHaveLength(1); - expect(findTestSubject(wrapper, 'userMenuLink__link1')).toHaveLength(1); - expect(findTestSubject(wrapper, 'userMenuLink__link2')).toHaveLength(1); - expect(findTestSubject(wrapper, 'userMenuLink__link3')).toHaveLength(1); - expect(findTestSubject(wrapper, 'logoutLink')).toHaveLength(1); - }); - - it('properly renders a popover for anonymous user.', async () => { - const props = { - user: Promise.resolve( - mockAuthenticatedUser({ - authentication_provider: { type: 'anonymous', name: 'does no matter' }, - }) - ), - editProfileUrl: '', - logoutUrl: '', - userMenuLinks$: new BehaviorSubject([ - { label: 'link1', href: 'path-to-link-1', iconType: 'empty', order: 1 }, - { label: 'link2', href: 'path-to-link-2', iconType: 'empty', order: 2 }, - { label: 'link3', href: 'path-to-link-3', iconType: 'empty', order: 3 }, - ]), - }; - - const wrapper = mountWithIntl(); - await nextTick(); - wrapper.update(); - - expect(findTestSubject(wrapper, 'userMenu')).toHaveLength(0); - expect(findTestSubject(wrapper, 'profileLink')).toHaveLength(0); - expect(findTestSubject(wrapper, 'logoutLink')).toHaveLength(0); - - wrapper.find(EuiHeaderSectionItemButton).simulate('click'); - - expect(findTestSubject(wrapper, 'userMenu')).toHaveLength(1); - expect(findTestSubject(wrapper, 'profileLink')).toHaveLength(0); - expect(findTestSubject(wrapper, 'logoutLink')).toHaveLength(1); - - expect(findTestSubject(wrapper, 'logoutLink').text()).toBe('Log in'); + it('should render additional user menu links registered by other plugins', async () => { + const wrapper = shallow( + + ); + + expect(wrapper.find(EuiContextMenu).prop('panels')).toMatchInlineSnapshot(` + Array [ + Object { + "id": 0, + "items": Array [ + Object { + "data-test-subj": "profileLink", + "href": "", + "icon": , + "name": , + }, + Object { + "data-test-subj": "userMenuLink__link1", + "href": "path-to-link-1", + "icon": , + "name": "link1", + }, + Object { + "data-test-subj": "userMenuLink__link2", + "href": "path-to-link-2", + "icon": , + "name": "link2", + }, + Object { + "data-test-subj": "userMenuLink__link3", + "href": "path-to-link-3", + "icon": , + "name": "link3", + }, + Object { + "data-test-subj": "logoutLink", + "href": "", + "icon": , + "name": , + }, + ], + "title": "full name", + }, + ] + `); }); - it('properly renders without a custom profile link.', async () => { - const props = { - user: Promise.resolve(mockAuthenticatedUser({ full_name: 'foo' })), - editProfileUrl: '', - logoutUrl: '', - userMenuLinks$: new BehaviorSubject([ - { label: 'link1', href: 'path-to-link-1', iconType: 'empty', order: 1 }, - { label: 'link2', href: 'path-to-link-2', iconType: 'empty', order: 2 }, - ]), - }; - - const wrapper = mountWithIntl(); - await nextTick(); - wrapper.update(); - - expect(wrapper.find(EuiContextMenuItem).map((node) => node.text())).toEqual([]); - - wrapper.find(EuiHeaderSectionItemButton).simulate('click'); - - expect(wrapper.find(EuiContextMenuItem).map((node) => node.text())).toEqual([ - 'Profile', - 'link1', - 'link2', - 'Log out', - ]); + it('should render custom profile link registered by other plugins', async () => { + const wrapper = shallow( + + ); + + expect(wrapper.find(EuiContextMenu).prop('panels')).toMatchInlineSnapshot(` + Array [ + Object { + "id": 0, + "items": Array [ + Object { + "data-test-subj": "userMenuLink__link1", + "href": "path-to-link-1", + "icon": , + "name": "link1", + }, + Object { + "data-test-subj": "userMenuLink__link2", + "href": "path-to-link-2", + "icon": , + "name": "link2", + }, + Object { + "data-test-subj": "userMenuLink__link3", + "href": "path-to-link-3", + "icon": , + "name": "link3", + }, + Object { + "data-test-subj": "profileLink", + "href": "", + "icon": , + "name": , + }, + Object { + "data-test-subj": "logoutLink", + "href": "", + "icon": , + "name": , + }, + ], + "title": "full name", + }, + ] + `); }); - it('properly renders with a custom profile link.', async () => { - const props = { - user: Promise.resolve(mockAuthenticatedUser({ full_name: 'foo' })), - editProfileUrl: '', - logoutUrl: '', - userMenuLinks$: new BehaviorSubject([ - { label: 'link1', href: 'path-to-link-1', iconType: 'empty', order: 1 }, - { label: 'link2', href: 'path-to-link-2', iconType: 'empty', order: 2, setAsProfile: true }, - ]), - }; - - const wrapper = mountWithIntl(); - await nextTick(); - wrapper.update(); - - expect(wrapper.find(EuiContextMenuItem).map((node) => node.text())).toEqual([]); - - wrapper.find(EuiHeaderSectionItemButton).simulate('click'); - - expect(wrapper.find(EuiContextMenuItem).map((node) => node.text())).toEqual([ - 'link1', - 'link2', - 'Preferences', - 'Log out', - ]); + it('should render anonymous user', async () => { + useUserProfileMock.mockReturnValue({ + loading: false, + value: undefined, + error: new Error('404'), + }); + + useCurrentUserMock.mockReturnValue({ + loading: false, + value: mockAuthenticatedUser({ + authentication_provider: { type: 'anonymous', name: 'does no matter' }, + }), + }); + + const wrapper = shallow( + + ); + + expect(wrapper.find(EuiContextMenu).prop('panels')).toMatchInlineSnapshot(` + Array [ + Object { + "id": 0, + "items": Array [ + Object { + "data-test-subj": "logoutLink", + "href": "", + "icon": , + "name": , + }, + ], + "title": "full name", + }, + ] + `); }); }); diff --git a/x-pack/plugins/security/public/nav_control/nav_control_component.tsx b/x-pack/plugins/security/public/nav_control/nav_control_component.tsx index 541d0c161a87e..8a37bb5c6153a 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_component.tsx +++ b/x-pack/plugins/security/public/nav_control/nav_control_component.tsx @@ -5,24 +5,26 @@ * 2.0. */ -import './nav_control_component.scss'; - import type { EuiContextMenuPanelItemDescriptor, IconType } from '@elastic/eui'; import { - EuiAvatar, EuiContextMenu, EuiHeaderSectionItemButton, EuiIcon, EuiLoadingSpinner, EuiPopover, } from '@elastic/eui'; -import React, { Component } from 'react'; -import type { Observable, Subscription } from 'rxjs'; +import type { FunctionComponent } from 'react'; +import React, { useState } from 'react'; +import useObservable from 'react-use/lib/useObservable'; +import type { Observable } from 'rxjs'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import type { AuthenticatedUser } from '../../common/model'; +import type { UserAvatar as IUserAvatar } from '../../common'; +import { getUserDisplayName, isUserAnonymous } from '../../common/model'; +import { UserAvatar } from '../account_management'; +import { useCurrentUser, useUserProfile } from '../components'; export interface UserMenuLink { label: string; @@ -32,174 +34,131 @@ export interface UserMenuLink { setAsProfile?: boolean; } -interface Props { - user: Promise; +interface SecurityNavControlProps { editProfileUrl: string; logoutUrl: string; userMenuLinks$: Observable; } -interface State { - isOpen: boolean; - authenticatedUser: AuthenticatedUser | null; - userMenuLinks: UserMenuLink[]; -} - -export class SecurityNavControl extends Component { - private subscription?: Subscription; - - constructor(props: Props) { - super(props); - - this.state = { - isOpen: false, - authenticatedUser: null, - userMenuLinks: [], - }; - - props.user.then((authenticatedUser) => { - this.setState({ - authenticatedUser, - }); - }); - } - - componentDidMount() { - this.subscription = this.props.userMenuLinks$.subscribe(async (userMenuLinks) => { - this.setState({ userMenuLinks }); - }); - } - - componentWillUnmount() { - if (this.subscription) { - this.subscription.unsubscribe(); - } - } - - onMenuButtonClick = () => { - if (!this.state.authenticatedUser) { - return; - } - - this.setState({ - isOpen: !this.state.isOpen, - }); - }; - - closeMenu = () => { - this.setState({ - isOpen: false, - }); - }; - - render() { - const { editProfileUrl, logoutUrl } = this.props; - const { authenticatedUser, userMenuLinks } = this.state; - - const username = - (authenticatedUser && (authenticatedUser.full_name || authenticatedUser.username)) || ''; - - const buttonContents = authenticatedUser ? ( - - ) : ( - - ); - - const button = ( - - {buttonContents} - - ); - - const isAnonymousUser = authenticatedUser?.authentication_provider.type === 'anonymous'; - const items: EuiContextMenuPanelItemDescriptor[] = []; - - if (userMenuLinks.length) { - const userMenuLinkMenuItems = userMenuLinks - .sort(({ order: orderA = Infinity }, { order: orderB = Infinity }) => orderA - orderB) - .map(({ label, iconType, href }: UserMenuLink) => ({ - name: label, - icon: , - href, - 'data-test-subj': `userMenuLink__${label}`, - })); - items.push(...userMenuLinkMenuItems); - } - - if (!isAnonymousUser) { - const hasCustomProfileLinks = userMenuLinks.some(({ setAsProfile }) => setAsProfile === true); - const profileMenuItem = { - name: ( - - ), - icon: , - href: editProfileUrl, - 'data-test-subj': 'profileLink', - }; - - // Set this as the first link if there is no user-defined profile link - if (!hasCustomProfileLinks) { - items.unshift(profileMenuItem); - } else { - items.push(profileMenuItem); - } - } - - const logoutMenuItem = { - name: isAnonymousUser ? ( - = ({ + editProfileUrl, + logoutUrl, + userMenuLinks$, +}) => { + const userMenuLinks = useObservable(userMenuLinks$, []); + const [isOpen, setIsOpen] = useState(false); + + const userProfile = useUserProfile<{ avatar: IUserAvatar }>('avatar'); + const currentUser = useCurrentUser(); // User profiles do not exist for anonymous users so need to fetch current user as well + + const displayName = currentUser.value ? getUserDisplayName(currentUser.value) : ''; + + const button = ( + setIsOpen((value) => (currentUser.value ? !value : false))} + data-test-subj="userMenuButton" + style={{ lineHeight: 'normal' }} + > + {currentUser.value && userProfile.value ? ( + + ) : currentUser.value && userProfile.error ? ( + ) : ( + + )} + + ); + + const isAnonymous = currentUser.value ? isUserAnonymous(currentUser.value) : false; + const items: EuiContextMenuPanelItemDescriptor[] = []; + if (userMenuLinks.length) { + const userMenuLinkMenuItems = userMenuLinks + .sort(({ order: orderA = Infinity }, { order: orderB = Infinity }) => orderA - orderB) + .map(({ label, iconType, href }: UserMenuLink) => ({ + name: label, + icon: , + href, + 'data-test-subj': `userMenuLink__${label}`, + })); + items.push(...userMenuLinkMenuItems); + } + + if (!isAnonymous) { + const hasCustomProfileLinks = userMenuLinks.some(({ setAsProfile }) => setAsProfile === true); + const profileMenuItem: EuiContextMenuPanelItemDescriptor = { + name: ( ), - icon: , - href: logoutUrl, - 'data-test-subj': 'logoutLink', + icon: , + href: editProfileUrl, + 'data-test-subj': 'profileLink', }; - items.push(logoutMenuItem); - const panels = [ - { - id: 0, - title: username, - items, - }, - ]; - - return ( - -
- -
-
- ); + // Set this as the first link if there is no user-defined profile link + if (!hasCustomProfileLinks) { + items.unshift(profileMenuItem); + } else { + items.push(profileMenuItem); + } } -} + + items.push({ + name: isAnonymous ? ( + + ) : ( + + ), + icon: , + href: logoutUrl, + 'data-test-subj': 'logoutLink', + }); + + return ( + setIsOpen(false)} + panelPaddingSize="none" + buffer={0} + > +
+ +
+
+ ); +}; diff --git a/x-pack/plugins/security/public/nav_control/nav_control_service.test.ts b/x-pack/plugins/security/public/nav_control/nav_control_service.test.ts index c801a1f8f4e1b..93d2927f678a8 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_service.test.ts +++ b/x-pack/plugins/security/public/nav_control/nav_control_service.test.ts @@ -7,15 +7,29 @@ import { BehaviorSubject } from 'rxjs'; +import type { httpServiceMock } from '@kbn/core/public/mocks'; import { coreMock } from '@kbn/core/public/mocks'; import type { ILicense } from '@kbn/licensing-plugin/public'; import { nextTick } from '@kbn/test-jest-helpers'; import { SecurityLicenseService } from '../../common/licensing'; -import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock'; -import { securityMock } from '../mocks'; +import { UserProfileAPIClient } from '../account_management'; +import { authenticationMock } from '../authentication/index.mock'; +import * as UseCurrentUserImports from '../components/use_current_user'; +import { UserAPIClient } from '../management'; import { SecurityNavControlService } from './nav_control_service'; +const useUserProfileMock = jest.spyOn(UseCurrentUserImports, 'useUserProfile'); +const useCurrentUserMock = jest.spyOn(UseCurrentUserImports, 'useCurrentUser'); + +useUserProfileMock.mockReturnValue({ + loading: true, +}); + +useCurrentUserMock.mockReturnValue({ + loading: true, +}); + const validLicense = { isAvailable: true, getFeature: (feature) => { @@ -29,25 +43,28 @@ const validLicense = { hasAtLeast: (...candidates) => true, } as ILicense; +const authc = authenticationMock.createStart(); + +const mockApiClients = (http: ReturnType) => ({ + userProfiles: new UserProfileAPIClient(http), + users: new UserAPIClient(http), +}); + describe('SecurityNavControlService', () => { it('can render and cleanup the control via the mount() function', async () => { const license$ = new BehaviorSubject(validLicense); + const coreStart = coreMock.createStart(); const navControlService = new SecurityNavControlService(); - const mockSecuritySetup = securityMock.createSetup(); - mockSecuritySetup.authc.getCurrentUser.mockResolvedValue( - mockAuthenticatedUser({ username: 'some-user', full_name: undefined }) - ); navControlService.setup({ securityLicense: new SecurityLicenseService().setup({ license$ }).license, - authc: mockSecuritySetup.authc, logoutUrl: '/some/logout/url', + apiClients: mockApiClients(coreStart.http), }); - const coreStart = coreMock.createStart(); coreStart.chrome.navControls.registerRight = jest.fn(); - navControlService.start({ core: coreStart }); + navControlService.start({ core: coreStart, authc }); expect(coreStart.chrome.navControls.registerRight).toHaveBeenCalledTimes(1); const [{ mount }] = coreStart.chrome.navControls.registerRight.mock.calls[0]; @@ -72,6 +89,7 @@ describe('SecurityNavControlService', () => { aria-label="Account menu" class="euiButtonEmpty euiButtonEmpty--text euiHeaderSectionItemButton" data-test-subj="userMenuButton" + style="line-height: normal;" type="button" > { - + @@ -113,16 +120,16 @@ describe('SecurityNavControlService', () => { it('should register the nav control once the license supports it', () => { const license$ = new BehaviorSubject({} as ILicense); + const coreStart = coreMock.createStart(); const navControlService = new SecurityNavControlService(); navControlService.setup({ securityLicense: new SecurityLicenseService().setup({ license$ }).license, - authc: securityMock.createSetup().authc, logoutUrl: '/some/logout/url', + apiClients: mockApiClients(coreStart.http), }); - const coreStart = coreMock.createStart(); - navControlService.start({ core: coreStart }); + navControlService.start({ core: coreStart, authc }); expect(coreStart.chrome.navControls.registerRight).not.toHaveBeenCalled(); @@ -133,33 +140,33 @@ describe('SecurityNavControlService', () => { it('should not register the nav control for anonymous paths', () => { const license$ = new BehaviorSubject(validLicense); + const coreStart = coreMock.createStart(); const navControlService = new SecurityNavControlService(); navControlService.setup({ securityLicense: new SecurityLicenseService().setup({ license$ }).license, - authc: securityMock.createSetup().authc, logoutUrl: '/some/logout/url', + apiClients: mockApiClients(coreStart.http), }); - const coreStart = coreMock.createStart(); coreStart.http.anonymousPaths.isAnonymous.mockReturnValue(true); - navControlService.start({ core: coreStart }); + navControlService.start({ core: coreStart, authc }); expect(coreStart.chrome.navControls.registerRight).not.toHaveBeenCalled(); }); it('should only register the nav control once', () => { const license$ = new BehaviorSubject(validLicense); + const coreStart = coreMock.createStart(); const navControlService = new SecurityNavControlService(); navControlService.setup({ securityLicense: new SecurityLicenseService().setup({ license$ }).license, - authc: securityMock.createSetup().authc, logoutUrl: '/some/logout/url', + apiClients: mockApiClients(coreStart.http), }); - const coreStart = coreMock.createStart(); - navControlService.start({ core: coreStart }); + navControlService.start({ core: coreStart, authc }); expect(coreStart.chrome.navControls.registerRight).toHaveBeenCalledTimes(1); @@ -172,48 +179,52 @@ describe('SecurityNavControlService', () => { it('should allow for re-registration if the service is restarted', () => { const license$ = new BehaviorSubject(validLicense); + const coreStart = coreMock.createStart(); const navControlService = new SecurityNavControlService(); navControlService.setup({ securityLicense: new SecurityLicenseService().setup({ license$ }).license, - authc: securityMock.createSetup().authc, logoutUrl: '/some/logout/url', + apiClients: mockApiClients(coreStart.http), }); - const coreStart = coreMock.createStart(); - navControlService.start({ core: coreStart }); + navControlService.start({ core: coreStart, authc }); expect(coreStart.chrome.navControls.registerRight).toHaveBeenCalledTimes(1); navControlService.stop(); - navControlService.start({ core: coreStart }); + navControlService.start({ core: coreStart, authc }); expect(coreStart.chrome.navControls.registerRight).toHaveBeenCalledTimes(2); }); describe(`#start`, () => { let navControlService: SecurityNavControlService; beforeEach(() => { + const coreSetup = coreMock.createSetup(); const license$ = new BehaviorSubject({} as ILicense); navControlService = new SecurityNavControlService(); navControlService.setup({ securityLicense: new SecurityLicenseService().setup({ license$ }).license, - authc: securityMock.createSetup().authc, logoutUrl: '/some/logout/url', + apiClients: mockApiClients(coreSetup.http), }); }); it('should return functions to register and retrieve user menu links', () => { const coreStart = coreMock.createStart(); - const navControlServiceStart = navControlService.start({ core: coreStart }); + const navControlServiceStart = navControlService.start({ core: coreStart, authc }); expect(navControlServiceStart).toHaveProperty('getUserMenuLinks$'); expect(navControlServiceStart).toHaveProperty('addUserMenuLinks'); }); it('should register custom user menu links to be displayed in the nav controls', (done) => { const coreStart = coreMock.createStart(); - const { getUserMenuLinks$, addUserMenuLinks } = navControlService.start({ core: coreStart }); + const { getUserMenuLinks$, addUserMenuLinks } = navControlService.start({ + core: coreStart, + authc, + }); const userMenuLinks$ = getUserMenuLinks$(); addUserMenuLinks([ @@ -240,7 +251,10 @@ describe('SecurityNavControlService', () => { it('should retrieve user menu links sorted by order', (done) => { const coreStart = coreMock.createStart(); - const { getUserMenuLinks$, addUserMenuLinks } = navControlService.start({ core: coreStart }); + const { getUserMenuLinks$, addUserMenuLinks } = navControlService.start({ + core: coreStart, + authc, + }); const userMenuLinks$ = getUserMenuLinks$(); addUserMenuLinks([ @@ -307,7 +321,10 @@ describe('SecurityNavControlService', () => { it('should allow adding a custom profile link', () => { const coreStart = coreMock.createStart(); - const { getUserMenuLinks$, addUserMenuLinks } = navControlService.start({ core: coreStart }); + const { getUserMenuLinks$, addUserMenuLinks } = navControlService.start({ + core: coreStart, + authc, + }); const userMenuLinks$ = getUserMenuLinks$(); addUserMenuLinks([ @@ -327,7 +344,10 @@ describe('SecurityNavControlService', () => { it('should not allow adding more than one custom profile link', () => { const coreStart = coreMock.createStart(); - const { getUserMenuLinks$, addUserMenuLinks } = navControlService.start({ core: coreStart }); + const { getUserMenuLinks$, addUserMenuLinks } = navControlService.start({ + core: coreStart, + authc, + }); const userMenuLinks$ = getUserMenuLinks$(); expect(() => { diff --git a/x-pack/plugins/security/public/nav_control/nav_control_service.tsx b/x-pack/plugins/security/public/nav_control/nav_control_service.tsx index 63e753f8646cd..47cf1027851f9 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_service.tsx +++ b/x-pack/plugins/security/public/nav_control/nav_control_service.tsx @@ -6,28 +6,33 @@ */ import { sortBy } from 'lodash'; +import type { FunctionComponent } from 'react'; import React from 'react'; import ReactDOM from 'react-dom'; import type { Observable, Subscription } from 'rxjs'; import { BehaviorSubject, ReplaySubject } from 'rxjs'; import { map, takeUntil } from 'rxjs/operators'; -import type { CoreStart } from '@kbn/core/public'; -import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; +import type { CoreStart, CoreTheme } from '@kbn/core/public'; +import { I18nProvider } from '@kbn/i18n-react'; +import { KibanaContextProvider, KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; import type { SecurityLicense } from '../../common/licensing'; import type { AuthenticationServiceSetup } from '../authentication'; +import type { ApiClients } from '../components'; +import { ApiClientsProvider, AuthenticationProvider } from '../components'; import type { UserMenuLink } from './nav_control_component'; import { SecurityNavControl } from './nav_control_component'; interface SetupDeps { securityLicense: SecurityLicense; - authc: AuthenticationServiceSetup; logoutUrl: string; + apiClients: ApiClients; } interface StartDeps { core: CoreStart; + authc: AuthenticationServiceSetup; } export interface SecurityNavControlServiceStart { @@ -44,8 +49,8 @@ export interface SecurityNavControlServiceStart { export class SecurityNavControlService { private securityLicense!: SecurityLicense; - private authc!: AuthenticationServiceSetup; private logoutUrl!: string; + private apiClients!: ApiClients; private navControlRegistered!: boolean; @@ -54,13 +59,13 @@ export class SecurityNavControlService { private readonly stop$ = new ReplaySubject(1); private userMenuLinks$ = new BehaviorSubject([]); - public setup({ securityLicense, authc, logoutUrl }: SetupDeps) { + public setup({ securityLicense, logoutUrl, apiClients }: SetupDeps) { this.securityLicense = securityLicense; - this.authc = authc; this.logoutUrl = logoutUrl; + this.apiClients = apiClients; } - public start({ core }: StartDeps): SecurityNavControlServiceStart { + public start({ core, authc }: StartDeps): SecurityNavControlServiceStart { this.securityFeaturesSubscription = this.securityLicense.features$.subscribe( ({ showLinks }) => { const isAnonymousPath = core.http.anonymousPaths.isAnonymous(window.location.pathname); @@ -68,7 +73,7 @@ export class SecurityNavControlService { const shouldRegisterNavControl = !isAnonymousPath && showLinks && !this.navControlRegistered; if (shouldRegisterNavControl) { - this.registerSecurityNavControl(core); + this.registerSecurityNavControl(core, authc); } } ); @@ -110,32 +115,23 @@ export class SecurityNavControlService { this.stop$.next(); } - private registerSecurityNavControl( - core: Pick - ) { + private registerSecurityNavControl(core: CoreStart, authc: AuthenticationServiceSetup) { const { theme$ } = core.theme; - const currentUserPromise = this.authc.getCurrentUser(); core.chrome.navControls.registerRight({ order: 2000, - mount: (el: HTMLElement) => { - const I18nContext = core.i18n.Context; - - const props = { - user: currentUserPromise, - editProfileUrl: core.http.basePath.prepend('/security/account'), - logoutUrl: this.logoutUrl, - userMenuLinks$: this.userMenuLinks$, - }; + mount: (element: HTMLElement) => { ReactDOM.render( - - - - - , - el + + + , + element ); - return () => ReactDOM.unmountComponentAtNode(el); + return () => ReactDOM.unmountComponentAtNode(element); }, }); @@ -146,3 +142,28 @@ export class SecurityNavControlService { return sortBy(userMenuLinks, 'order'); } } + +export interface ProvidersProps { + authc: AuthenticationServiceSetup; + services: CoreStart; + apiClients: ApiClients; + theme$: Observable; +} + +export const Providers: FunctionComponent = ({ + authc, + services, + theme$, + apiClients, + children, +}) => ( + + + + + {children} + + + + +); diff --git a/x-pack/plugins/security/public/plugin.tsx b/x-pack/plugins/security/public/plugin.tsx index 7ca2e2f353efa..98505189d459c 100644 --- a/x-pack/plugins/security/public/plugin.tsx +++ b/x-pack/plugins/security/public/plugin.tsx @@ -23,12 +23,12 @@ import type { SpacesPluginStart } from '@kbn/spaces-plugin/public'; import { SecurityLicenseService } from '../common/licensing'; import type { SecurityLicense } from '../common/licensing'; -import { accountManagementApp } from './account_management'; +import { accountManagementApp, UserProfileAPIClient } from './account_management'; import { AnonymousAccessService } from './anonymous_access'; import type { AuthenticationServiceSetup, AuthenticationServiceStart } from './authentication'; import { AuthenticationService } from './authentication'; import type { ConfigType } from './config'; -import { ManagementService } from './management'; +import { ManagementService, UserAPIClient } from './management'; import type { SecurityNavControlServiceStart } from './nav_control'; import { SecurityNavControlService } from './nav_control'; import { SecurityCheckupService } from './security_checkup'; @@ -91,16 +91,22 @@ export class SecurityPlugin http: core.http, }); + const apiClients = { + userProfiles: new UserProfileAPIClient(core.http), + users: new UserAPIClient(core.http), + }; + this.navControlService.setup({ securityLicense: license, - authc: this.authc, logoutUrl: getLogoutUrl(core.http), + apiClients, }); accountManagementApp.create({ authc: this.authc, application: core.application, getStartServices: core.getStartServices, + apiClients, }); if (management) { @@ -167,7 +173,7 @@ export class SecurityPlugin return { uiApi: getUiApi({ core }), - navControlService: this.navControlService.start({ core }), + navControlService: this.navControlService.start({ core, authc: this.authc }), authc: this.authc as AuthenticationServiceStart, }; } @@ -206,6 +212,7 @@ export interface SecurityPluginStart { authc: AuthenticationServiceStart; /** * Exposes UI components that will be loaded asynchronously. + * @deprecated */ uiApi: UiApi; } diff --git a/x-pack/plugins/security/public/account_management/change_password/change_password.tsx b/x-pack/plugins/security/public/ui_api/change_password/change_password.tsx similarity index 100% rename from x-pack/plugins/security/public/account_management/change_password/change_password.tsx rename to x-pack/plugins/security/public/ui_api/change_password/change_password.tsx diff --git a/x-pack/plugins/security/public/account_management/change_password/change_password_async.tsx b/x-pack/plugins/security/public/ui_api/change_password/change_password_async.tsx similarity index 100% rename from x-pack/plugins/security/public/account_management/change_password/change_password_async.tsx rename to x-pack/plugins/security/public/ui_api/change_password/change_password_async.tsx diff --git a/x-pack/plugins/security/public/account_management/change_password/index.ts b/x-pack/plugins/security/public/ui_api/change_password/index.ts similarity index 100% rename from x-pack/plugins/security/public/account_management/change_password/index.ts rename to x-pack/plugins/security/public/ui_api/change_password/index.ts diff --git a/x-pack/plugins/security/public/ui_api/components.tsx b/x-pack/plugins/security/public/ui_api/components.tsx index 5c899ab8c5ab5..7ab626bad1571 100644 --- a/x-pack/plugins/security/public/ui_api/components.tsx +++ b/x-pack/plugins/security/public/ui_api/components.tsx @@ -18,9 +18,9 @@ import type { CoreStart } from '@kbn/core/public'; * It happens because the bundle starts to also include all the sync dependencies * available through the index file. */ -import { getChangePasswordComponent } from '../account_management/change_password/change_password_async'; -import { getPersonalInfoComponent } from '../account_management/personal_info/personal_info_async'; +import { getChangePasswordComponent } from './change_password/change_password_async'; import { LazyWrapper } from './lazy_wrapper'; +import { getPersonalInfoComponent } from './personal_info/personal_info_async'; export interface GetComponentsOptions { core: CoreStart; diff --git a/x-pack/plugins/security/public/ui_api/index.ts b/x-pack/plugins/security/public/ui_api/index.ts index ba2e1bfb5f36b..7029a0547c70a 100644 --- a/x-pack/plugins/security/public/ui_api/index.ts +++ b/x-pack/plugins/security/public/ui_api/index.ts @@ -9,8 +9,11 @@ import type { ReactElement } from 'react'; import type { CoreStart } from '@kbn/core/public'; -import type { ChangePasswordProps, PersonalInfoProps } from '../account_management'; +import type { ChangePasswordProps } from './change_password'; import { getComponents } from './components'; +import type { PersonalInfoProps } from './personal_info'; + +export type { ChangePasswordProps, PersonalInfoProps }; interface GetUiApiOptions { core: CoreStart; diff --git a/x-pack/plugins/security/public/account_management/personal_info/index.ts b/x-pack/plugins/security/public/ui_api/personal_info/index.ts similarity index 100% rename from x-pack/plugins/security/public/account_management/personal_info/index.ts rename to x-pack/plugins/security/public/ui_api/personal_info/index.ts diff --git a/x-pack/plugins/security/public/account_management/personal_info/personal_info.tsx b/x-pack/plugins/security/public/ui_api/personal_info/personal_info.tsx similarity index 100% rename from x-pack/plugins/security/public/account_management/personal_info/personal_info.tsx rename to x-pack/plugins/security/public/ui_api/personal_info/personal_info.tsx diff --git a/x-pack/plugins/security/public/account_management/personal_info/personal_info_async.tsx b/x-pack/plugins/security/public/ui_api/personal_info/personal_info_async.tsx similarity index 100% rename from x-pack/plugins/security/public/account_management/personal_info/personal_info_async.tsx rename to x-pack/plugins/security/public/ui_api/personal_info/personal_info_async.tsx diff --git a/x-pack/plugins/security/server/authentication/authenticator.test.ts b/x-pack/plugins/security/server/authentication/authenticator.test.ts index 3b4150bbcaa8d..0959479e8023f 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.test.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.test.ts @@ -27,6 +27,7 @@ import { } from '../../common/constants'; import { licenseMock } from '../../common/licensing/index.mock'; import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock'; +import { userProfileMock } from '../../common/model/user_profile.mock'; import type { AuditLogger } from '../audit'; import { auditLoggerMock, auditServiceMock } from '../audit/mocks'; import { ConfigSchema, createConfig } from '../config'; @@ -35,7 +36,6 @@ import { securityMock } from '../mocks'; import type { SessionValue } from '../session_management'; import { sessionMock } from '../session_management/index.mock'; import type { UserProfileGrant } from '../user_profile'; -import { userProfileMock } from '../user_profile/user_profile.mock'; import { userProfileServiceMock } from '../user_profile/user_profile_service.mock'; import { AuthenticationResult } from './authentication_result'; import type { AuthenticatorOptions } from './authenticator'; diff --git a/x-pack/plugins/security/server/routes/user_profile/get.ts b/x-pack/plugins/security/server/routes/user_profile/get.ts index 9bfa2fe16a682..d1212dc064f7a 100644 --- a/x-pack/plugins/security/server/routes/user_profile/get.ts +++ b/x-pack/plugins/security/server/routes/user_profile/get.ts @@ -7,27 +7,43 @@ import { schema } from '@kbn/config-schema'; -import type { RouteDefinitionParams } from '../'; +import type { RouteDefinitionParams } from '..'; +import type { AuthenticatedUserProfile } from '../../../common'; import { wrapIntoCustomErrorResponse } from '../../errors'; import { createLicensedRouteHandler } from '../licensed_route_handler'; export function defineGetUserProfileRoute({ router, + getSession, getUserProfileService, + logger, }: RouteDefinitionParams) { router.get( { - path: '/internal/security/user_profile/{uid}', - options: { tags: ['access:accessUserProfile'] }, + path: '/internal/security/user_profile', validate: { - params: schema.object({ uid: schema.string() }), + query: schema.object({ data: schema.maybe(schema.string()) }), }, }, createLicensedRouteHandler(async (context, request, response) => { + const session = await getSession().get(request); + if (!session) { + return response.notFound(); + } + + if (!session.userProfileId) { + logger.warn(`User profile missing from current session. (sid: ${session.sid.slice(-10)})`); + return response.notFound(); + } + const userProfileService = getUserProfileService(); try { - const profile = await userProfileService.get(request.params.uid, '*'); - return response.ok({ body: profile }); + const profile = await userProfileService.get(session.userProfileId, request.query.data); + const body: AuthenticatedUserProfile = { + ...profile, + user: { ...profile.user, authentication_provider: session.provider }, + }; + return response.ok({ body }); } catch (error) { return response.customError(wrapIntoCustomErrorResponse(error)); } diff --git a/x-pack/plugins/security/server/routes/user_profile/index.ts b/x-pack/plugins/security/server/routes/user_profile/index.ts index 293ec0e75cf30..6d526d2ce6a75 100644 --- a/x-pack/plugins/security/server/routes/user_profile/index.ts +++ b/x-pack/plugins/security/server/routes/user_profile/index.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { RouteDefinitionParams } from '../index'; +import type { RouteDefinitionParams } from '..'; import { defineGetUserProfileRoute } from './get'; import { defineUpdateUserProfileDataRoute } from './update'; diff --git a/x-pack/plugins/security/server/routes/user_profile/update.ts b/x-pack/plugins/security/server/routes/user_profile/update.ts index 00cbfea3a957b..333821508e96e 100644 --- a/x-pack/plugins/security/server/routes/user_profile/update.ts +++ b/x-pack/plugins/security/server/routes/user_profile/update.ts @@ -7,27 +7,38 @@ import { schema } from '@kbn/config-schema'; -import type { RouteDefinitionParams } from '../'; +import type { RouteDefinitionParams } from '..'; import { wrapIntoCustomErrorResponse } from '../../errors'; import { createLicensedRouteHandler } from '../licensed_route_handler'; export function defineUpdateUserProfileDataRoute({ router, + getSession, getUserProfileService, + logger, }: RouteDefinitionParams) { router.post( { - path: '/internal/security/user_profile/_data/{uid}', - options: { tags: ['access:updateUserProfile'] }, + path: '/internal/security/user_profile/_data', validate: { - params: schema.object({ uid: schema.string() }), body: schema.recordOf(schema.string(), schema.any()), }, }, createLicensedRouteHandler(async (context, request, response) => { + const session = await getSession().get(request); + if (!session) { + logger.warn('User profile requested without valid session.'); + return response.notFound(); + } + + if (!session.userProfileId) { + logger.warn(`User profile missing from current session. (sid: ${session.sid.slice(-10)})`); + return response.notFound(); + } + const userProfileService = getUserProfileService(); try { - await userProfileService.update(request.params.uid, request.body); + await userProfileService.update(session.userProfileId, request.body); return response.ok(); } catch (error) { return response.customError(wrapIntoCustomErrorResponse(error)); diff --git a/x-pack/plugins/security/server/user_profile/index.ts b/x-pack/plugins/security/server/user_profile/index.ts index b9650bd5fb2ee..dd058107ffa60 100644 --- a/x-pack/plugins/security/server/user_profile/index.ts +++ b/x-pack/plugins/security/server/user_profile/index.ts @@ -10,5 +10,4 @@ export type { UserProfileServiceStart, UserProfileServiceStartParams, } from './user_profile_service'; -export type { UserProfile, UserData } from './user_profile'; export type { UserProfileGrant } from './user_profile_grant'; diff --git a/x-pack/plugins/security/server/user_profile/user_profile.mock.ts b/x-pack/plugins/security/server/user_profile/user_profile.mock.ts deleted file mode 100644 index 63573b62215f5..0000000000000 --- a/x-pack/plugins/security/server/user_profile/user_profile.mock.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - * 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 type { UserProfile } from './user_profile'; - -export const userProfileMock = { - create: (userProfile: Partial = {}): UserProfile => ({ - uid: 'some-profile-uid', - enabled: true, - user: { username: 'some-username', active: true, roles: [], enabled: true }, - data: {}, - ...userProfile, - }), -}; diff --git a/x-pack/plugins/security/server/user_profile/user_profile.ts b/x-pack/plugins/security/server/user_profile/user_profile.ts deleted file mode 100644 index 0525cacc10614..0000000000000 --- a/x-pack/plugins/security/server/user_profile/user_profile.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* - * 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 type { User } from '../../common'; - -export interface UserInfo extends User { - display_name?: string; - avatar?: { - initials?: string; - color?: string; - image_url?: string; - }; - active: boolean; -} - -export type UserData = Record; - -/** - * Describes properties of the user's profile. - */ -export interface UserProfile> { - /** - * Unique ID for of the user profile. - */ - uid: string; - - /** - * Indicates whether user profile is enabled or not. - */ - enabled: boolean; - - /** - * Information about the user that owns profile. - */ - user: UserInfo; - - /** - * User specific data associated with the profile. - */ - data: T; -} diff --git a/x-pack/plugins/security/server/user_profile/user_profile_service.mock.ts b/x-pack/plugins/security/server/user_profile/user_profile_service.mock.ts index fa7623d40d362..e6fd72c15f110 100644 --- a/x-pack/plugins/security/server/user_profile/user_profile_service.mock.ts +++ b/x-pack/plugins/security/server/user_profile/user_profile_service.mock.ts @@ -5,8 +5,8 @@ * 2.0. */ -import type { UserProfileServiceStart } from '../user_profile'; -import { userProfileMock } from './user_profile.mock'; +import type { UserProfileServiceStart } from '.'; +import { userProfileMock } from '../../common/model/user_profile.mock'; export const userProfileServiceMock = { createStart: (): jest.Mocked => ({ diff --git a/x-pack/plugins/security/server/user_profile/user_profile_service.test.ts b/x-pack/plugins/security/server/user_profile/user_profile_service.test.ts index e05d72e69b93f..2da6ec8de944b 100644 --- a/x-pack/plugins/security/server/user_profile/user_profile_service.test.ts +++ b/x-pack/plugins/security/server/user_profile/user_profile_service.test.ts @@ -7,10 +7,11 @@ import { errors } from '@elastic/elasticsearch'; -import { elasticsearchServiceMock, loggingSystemMock } from 'src/core/server/mocks'; +import { elasticsearchServiceMock, loggingSystemMock } from '@kbn/core/server/mocks'; +import { nextTick } from '@kbn/test-jest-helpers'; +import { userProfileMock } from '../../common/model/user_profile.mock'; import { securityMock } from '../mocks'; -import { userProfileMock } from './user_profile.mock'; import { UserProfileService } from './user_profile_service'; const logger = loggingSystemMock.createLogger(); @@ -61,20 +62,38 @@ describe('UserProfileService', () => { it('should get user profile', async () => { const startContract = userProfileService.start(mockStartParams); await expect(startContract.get('UID')).resolves.toMatchInlineSnapshot(` - Object { - "data": Object { - "avatar": "fun.gif", - }, - "enabled": true, - "uid": "UID", - "user": Object { - "active": true, - "enabled": true, - "roles": Array [], - "username": "some-username", - }, - } - `); + Object { + "data": Object { + "avatar": "fun.gif", + }, + "enabled": true, + "uid": "UID", + "user": Object { + "active": true, + "authentication_provider": Object { + "name": "basic1", + "type": "basic", + }, + "authentication_realm": Object { + "name": "native1", + "type": "native", + }, + "authentication_type": "realm", + "email": "email", + "enabled": true, + "full_name": "full name", + "lookup_realm": Object { + "name": "native1", + "type": "native", + }, + "metadata": Object { + "_reserved": false, + }, + "roles": Array [], + "username": "some-username", + }, + } + `); expect(mockStartParams.clusterClient.asInternalUser.transport.request).toHaveBeenCalledWith({ method: 'GET', path: '_security/profile/UID', @@ -93,20 +112,38 @@ describe('UserProfileService', () => { it('should get user profile and application data scoped to Kibana', async () => { const startContract = userProfileService.start(mockStartParams); await expect(startContract.get('UID', '*')).resolves.toMatchInlineSnapshot(` - Object { - "data": Object { - "avatar": "fun.gif", - }, - "enabled": true, - "uid": "UID", - "user": Object { - "active": true, - "enabled": true, - "roles": Array [], - "username": "some-username", - }, - } - `); + Object { + "data": Object { + "avatar": "fun.gif", + }, + "enabled": true, + "uid": "UID", + "user": Object { + "active": true, + "authentication_provider": Object { + "name": "basic1", + "type": "basic", + }, + "authentication_realm": Object { + "name": "native1", + "type": "native", + }, + "authentication_type": "realm", + "email": "email", + "enabled": true, + "full_name": "full name", + "lookup_realm": Object { + "name": "native1", + "type": "native", + }, + "metadata": Object { + "_reserved": false, + }, + "roles": Array [], + "username": "some-username", + }, + } + `); expect(mockStartParams.clusterClient.asInternalUser.transport.request).toHaveBeenCalledWith({ method: 'GET', path: '_security/profile/UID?data=kibana.*', @@ -129,7 +166,7 @@ describe('UserProfileService', () => { }, }, method: 'POST', - path: '_security/profile/_data/UID', + path: '_security/profile/UID/_data', }); }); @@ -163,18 +200,36 @@ describe('UserProfileService', () => { password: 'password', }) ).resolves.toMatchInlineSnapshot(` - Object { - "data": Object {}, - "enabled": true, - "uid": "some-profile-uid", - "user": Object { - "active": true, - "enabled": true, - "roles": Array [], - "username": "some-username", - }, - } - `); + Object { + "data": Object {}, + "enabled": true, + "uid": "some-profile-uid", + "user": Object { + "active": true, + "authentication_provider": Object { + "name": "basic1", + "type": "basic", + }, + "authentication_realm": Object { + "name": "native1", + "type": "native", + }, + "authentication_type": "realm", + "email": "email", + "enabled": true, + "full_name": "full name", + "lookup_realm": Object { + "name": "native1", + "type": "native", + }, + "metadata": Object { + "_reserved": false, + }, + "roles": Array [], + "username": "some-username", + }, + } + `); expect(mockStartParams.clusterClient.asInternalUser.transport.request).toHaveBeenCalledTimes( 1 ); @@ -189,18 +244,36 @@ describe('UserProfileService', () => { const startContract = userProfileService.start(mockStartParams); await expect(startContract.activate({ type: 'accessToken', accessToken: 'some-token' })) .resolves.toMatchInlineSnapshot(` - Object { - "data": Object {}, - "enabled": true, - "uid": "some-profile-uid", - "user": Object { - "active": true, - "enabled": true, - "roles": Array [], - "username": "some-username", - }, - } - `); + Object { + "data": Object {}, + "enabled": true, + "uid": "some-profile-uid", + "user": Object { + "active": true, + "authentication_provider": Object { + "name": "basic1", + "type": "basic", + }, + "authentication_realm": Object { + "name": "native1", + "type": "native", + }, + "authentication_type": "realm", + "email": "email", + "enabled": true, + "full_name": "full name", + "lookup_realm": Object { + "name": "native1", + "type": "native", + }, + "metadata": Object { + "_reserved": false, + }, + "roles": Array [], + "username": "some-username", + }, + } + `); expect(mockStartParams.clusterClient.asInternalUser.transport.request).toHaveBeenCalledTimes( 1 ); @@ -211,9 +284,9 @@ describe('UserProfileService', () => { }); }); - it('fails if activation fails', async () => { + it('fails if activation fails with non-409 error', async () => { const failureReason = new errors.ResponseError( - securityMock.createApiResponse({ statusCode: 409, body: 'some message' }) + securityMock.createApiResponse({ statusCode: 500, body: 'some message' }) ); mockStartParams.clusterClient.asInternalUser.transport.request.mockRejectedValue( failureReason @@ -232,5 +305,103 @@ describe('UserProfileService', () => { body: { grant_type: 'access_token', access_token: 'some-token' }, }); }); + + it('retries activation if initially fails with 409 error', async () => { + jest.useFakeTimers(); + + const failureReason = new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 409, body: 'some message' }) + ); + mockStartParams.clusterClient.asInternalUser.transport.request + .mockRejectedValueOnce(failureReason) + .mockResolvedValueOnce(userProfileMock.create()); + + const startContract = userProfileService.start(mockStartParams); + const activatePromise = startContract.activate({ + type: 'accessToken', + accessToken: 'some-token', + }); + await nextTick(); + jest.runAllTimers(); + + await expect(activatePromise).resolves.toMatchInlineSnapshot(` + Object { + "data": Object {}, + "enabled": true, + "uid": "some-profile-uid", + "user": Object { + "active": true, + "authentication_provider": Object { + "name": "basic1", + "type": "basic", + }, + "authentication_realm": Object { + "name": "native1", + "type": "native", + }, + "authentication_type": "realm", + "email": "email", + "enabled": true, + "full_name": "full name", + "lookup_realm": Object { + "name": "native1", + "type": "native", + }, + "metadata": Object { + "_reserved": false, + }, + "roles": Array [], + "username": "some-username", + }, + } + `); + expect(mockStartParams.clusterClient.asInternalUser.transport.request).toHaveBeenCalledTimes( + 2 + ); + expect(mockStartParams.clusterClient.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '_security/profile/_activate', + body: { grant_type: 'access_token', access_token: 'some-token' }, + }); + }); + + it('fails if activation max retries exceeded', async () => { + jest.useFakeTimers(); + + const failureReason = new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 409, body: 'some message' }) + ); + mockStartParams.clusterClient.asInternalUser.transport.request.mockRejectedValue( + failureReason + ); + + const startContract = userProfileService.start(mockStartParams); + + // Initial activation attempt. + const activatePromise = startContract.activate({ + type: 'accessToken', + accessToken: 'some-token', + }); + await nextTick(); + jest.runAllTimers(); + + // The first retry. + await nextTick(); + jest.runAllTimers(); + + // The second retry. + await nextTick(); + jest.runAllTimers(); + + await expect(activatePromise).rejects.toBe(failureReason); + expect(mockStartParams.clusterClient.asInternalUser.transport.request).toHaveBeenCalledTimes( + 3 + ); + expect(mockStartParams.clusterClient.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '_security/profile/_activate', + body: { grant_type: 'access_token', access_token: 'some-token' }, + }); + }); }); }); diff --git a/x-pack/plugins/security/server/user_profile/user_profile_service.ts b/x-pack/plugins/security/server/user_profile/user_profile_service.ts index dbdc6d17e1581..fcc62f87fd953 100644 --- a/x-pack/plugins/security/server/user_profile/user_profile_service.ts +++ b/x-pack/plugins/security/server/user_profile/user_profile_service.ts @@ -5,13 +5,15 @@ * 2.0. */ -import type { IClusterClient, Logger } from 'src/core/server'; +import type { IClusterClient, Logger } from '@kbn/core/server'; -import { getDetailedErrorMessage } from '../errors'; -import type { UserData, UserInfo, UserProfile } from './user_profile'; +import type { AuthenticationProvider, UserData, UserInfo, UserProfile } from '../../common'; +import { getDetailedErrorMessage, getErrorStatusCode } from '../errors'; import type { UserProfileGrant } from './user_profile_grant'; const KIBANA_DATA_ROOT = 'kibana'; +const ACTIVATION_MAX_RETRIES = 3; +const ACTIVATION_RETRY_SCALE_DURATION_MS = 150; export interface UserProfileServiceStart { /** @@ -46,6 +48,7 @@ type GetProfileResponse = Record< access: {}; enabled: boolean; last_synchronized: number; + authentication_provider: AuthenticationProvider; } >; @@ -62,23 +65,56 @@ export class UserProfileService { async function activate(grant: UserProfileGrant): Promise { logger.debug(`Activating user profile via ${grant.type} grant.`); - try { - const response = await clusterClient.asInternalUser.transport.request({ - method: 'POST', - path: '_security/profile/_activate', - body: - grant.type === 'password' - ? { grant_type: 'password', username: grant.username, password: grant.password } - : { grant_type: 'access_token', access_token: grant.accessToken }, - }); - - logger.debug(`Successfully activated profile for "${response.user.username}".`); - - return response; - } catch (err) { - logger.error(`Failed to activate user profile: ${getDetailedErrorMessage(err)}.`); - throw err; - } + const activateGrant = + grant.type === 'password' + ? { grant_type: 'password', username: grant.username, password: grant.password } + : { grant_type: 'access_token', access_token: grant.accessToken }; + + // Profile activation is a multistep process that might or might not cause profile document to be created or + // updated. If Elasticsearch needs to handle multiple profile activation requests for the same user in parallel + // it can hit document version conflicts and fail (409 status code). In this case it's safe to retry activation + // request after some time. Most of the Kibana users won't be affected by this issue, but there are edge cases + // when users can be hit by the conflicts during profile activation, e.g. for PKI or Kerberos authentication when + // client certificate/ticket changes and multiple requests can trigger profile re-activation at the same time. + let activationRetriesLeft = ACTIVATION_MAX_RETRIES; + do { + try { + const response = await clusterClient.asInternalUser.transport.request({ + method: 'POST', + path: '_security/profile/_activate', + body: activateGrant, + }); + + logger.debug(`Successfully activated profile for "${response.user.username}".`); + + return response; + } catch (err) { + const detailedErrorMessage = getDetailedErrorMessage(err); + if (getErrorStatusCode(err) !== 409) { + logger.error(`Failed to activate user profile: ${detailedErrorMessage}.`); + throw err; + } + + activationRetriesLeft--; + logger.error( + `Failed to activate user profile (retries left: ${activationRetriesLeft}): ${detailedErrorMessage}.` + ); + + if (activationRetriesLeft === 0) { + throw err; + } + } + + await new Promise((resolve) => + setTimeout( + resolve, + (ACTIVATION_MAX_RETRIES - activationRetriesLeft) * ACTIVATION_RETRY_SCALE_DURATION_MS + ) + ); + } while (activationRetriesLeft > 0); + + // This should be unreachable code, unless we have a bug in retry handling logic. + throw new Error('Failed to activate user profile, max retries exceeded.'); } async function get(uid: string, dataPath?: string) { @@ -89,8 +125,7 @@ export class UserProfileService { dataPath ? `?data=${KIBANA_DATA_ROOT}.${dataPath}` : '' }`, }); - const { user, enabled, data } = body[uid]; - return { uid, enabled, user, data: data[KIBANA_DATA_ROOT] }; + return { ...body[uid], data: body[uid].data[KIBANA_DATA_ROOT] ?? {} }; } catch (error) { logger.error(`Failed to retrieve user profile [uid=${uid}]: ${error.message}`); throw error; @@ -101,7 +136,7 @@ export class UserProfileService { try { await clusterClient.asInternalUser.transport.request({ method: 'POST', - path: `_security/profile/_data/${uid}`, + path: `_security/profile/${uid}/_data`, body: { data: { [KIBANA_DATA_ROOT]: data, diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index b62a957cfa927..3bf3d74491562 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -5367,10 +5367,10 @@ "sharedUXComponents.noDataPage.elasticAgentCard.noPermission.description": "Cette intégration n'est pas encore activée. Votre administrateur possède les autorisations requises pour l’activer.", "sharedUXComponents.noDataPage.elasticAgentCard.noPermission.title": "Contactez votre administrateur", "sharedUXComponents.noDataPage.elasticAgentCard.title": "Ajouter Elastic Agent", - "sharedUXPackages.noDataViewsPrompt.learnMore": "Envie d'en savoir plus ?", - "sharedUXPackages.noDataViewsPrompt.readDocumentation": "Lisez les documents", "sharedUXComponents.pageTemplate.noDataCard.description": "Continuer sans collecter de données", "sharedUXComponents.toolbar.buttons.addFromLibrary.libraryButtonLabel": "Ajouter depuis la bibliothèque", + "sharedUXPackages.noDataViewsPrompt.learnMore": "Envie d'en savoir plus ?", + "sharedUXPackages.noDataViewsPrompt.readDocumentation": "Lisez les documents", "telemetry.callout.appliesSettingTitle": "Les modifications apportées à ce paramètre s'appliquent dans {allOfKibanaText} et sont enregistrées automatiquement.", "telemetry.callout.appliesSettingTitle.allOfKibanaText": "tout Kibana", "telemetry.callout.clusterStatisticsDescription": "Voici un exemple des statistiques de cluster de base que nous collecterons. Cela comprend le nombre d'index, de partitions et de nœuds. Cela comprend également des statistiques d'utilisation de niveau élevé, comme l'état d'activation du monitoring.", @@ -22784,7 +22784,6 @@ "xpack.security.account.changePasswordTitle": "Mot de passe", "xpack.security.account.currentPasswordRequired": "Le mot de passe actuel est requis.", "xpack.security.account.noEmailMessage": "aucune adresse e-mail", - "xpack.security.account.pageTitle": "Paramètres de {strongUsername}", "xpack.security.account.passwordLengthDescription": "Le mot de passe est trop court.", "xpack.security.account.passwordsDoNotMatch": "Les mots de passe ne correspondent pas.", "xpack.security.account.usernameGroupDescription": "Vous ne pouvez pas modifier ces informations.", @@ -23196,22 +23195,6 @@ "xpack.security.management.roles.statusColumnName": "Statut", "xpack.security.management.roles.subtitle": "Appliquez les rôles aux groupes d'utilisateurs et gérez les autorisations dans toute la Suite.", "xpack.security.management.rolesTitle": "Rôles", - "xpack.security.management.users.changePasswordFlyout.confirmButton": "{isSubmitting, select, true{Modification du mot de passe…} other{Modifier le mot de passe}}", - "xpack.security.management.users.changePasswordFlyout.confirmPasswordInvalidError": "Les mots de passe ne correspondent pas.", - "xpack.security.management.users.changePasswordFlyout.confirmPasswordLabel": "Confirmer le mot de passe", - "xpack.security.management.users.changePasswordFlyout.confirmSystemPasswordButton": "{isSubmitting, select, true{Modification du mot de passe…} other{Modifier le mot de passe}}", - "xpack.security.management.users.changePasswordFlyout.currentPasswordInvalidError": "Mot de passe non valide.", - "xpack.security.management.users.changePasswordFlyout.currentPasswordLabel": "Mot de passe actuel", - "xpack.security.management.users.changePasswordFlyout.currentPasswordRequiredError": "Entrez votre mot de passe actuel.", - "xpack.security.management.users.changePasswordFlyout.errorMessage": "Impossible de modifier le mot de passe", - "xpack.security.management.users.changePasswordFlyout.passwordInvalidError": "Le mot de passe doit comporter au moins 6 caractères.", - "xpack.security.management.users.changePasswordFlyout.passwordLabel": "Nouveau mot de passe", - "xpack.security.management.users.changePasswordFlyout.passwordRequiredError": "Entrez un nouveau mot de passe.", - "xpack.security.management.users.changePasswordFlyout.successMessage": "Mot de passe modifié pour \"{username}\".", - "xpack.security.management.users.changePasswordFlyout.systemUserDescription": "Une fois modifié, vous devez mettre à jour manuellement votre fichier config avec le nouveau mot de passe et redémarrer Kibana.", - "xpack.security.management.users.changePasswordFlyout.systemUserTitle": "C'est extrêmement important !", - "xpack.security.management.users.changePasswordFlyout.systemUserWarning": "La modification de ce mot de passe empêchera Kibana de communiquer avec Elasticsearch.", - "xpack.security.management.users.changePasswordFlyout.title": "Modifier le mot de passe", "xpack.security.management.users.changePasswordFlyout.userLabel": "Utilisateur", "xpack.security.management.users.confirmDelete.cancelButtonLabel": "Annuler", "xpack.security.management.users.confirmDelete.cannotUndoWarning": "Vous ne pouvez pas récupérer des utilisateurs supprimés.", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index fe7056a5e3ec1..87f2b4c8b9f89 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -5469,10 +5469,10 @@ "sharedUXComponents.noDataPage.elasticAgentCard.noPermission.description": "この統合はまだ有効ではありません。管理者にはオンにするために必要なアクセス権があります。", "sharedUXComponents.noDataPage.elasticAgentCard.noPermission.title": "管理者にお問い合わせください", "sharedUXComponents.noDataPage.elasticAgentCard.title": "Elasticエージェントの追加", - "sharedUXPackages.noDataViewsPrompt.learnMore": "詳細について", - "sharedUXPackages.noDataViewsPrompt.readDocumentation": "ドキュメントを読む", "sharedUXComponents.pageTemplate.noDataCard.description": "データを収集せずに続行", "sharedUXComponents.toolbar.buttons.addFromLibrary.libraryButtonLabel": "ライブラリから追加", + "sharedUXPackages.noDataViewsPrompt.learnMore": "詳細について", + "sharedUXPackages.noDataViewsPrompt.readDocumentation": "ドキュメントを読む", "telemetry.callout.appliesSettingTitle": "この設定に加えた変更は {allOfKibanaText} に適用され、自動的に保存されます。", "telemetry.callout.appliesSettingTitle.allOfKibanaText": "Kibana のすべて", "telemetry.callout.clusterStatisticsDescription": "これは収集される基本的なクラスター統計の例です。インデックス、シャード、ノードの数が含まれます。監視がオンになっているかどうかなどのハイレベルの使用統計も含まれます。", @@ -22921,7 +22921,6 @@ "xpack.security.account.changePasswordTitle": "パスワード", "xpack.security.account.currentPasswordRequired": "現在のパスワードが必要です。", "xpack.security.account.noEmailMessage": "メールアドレスがありません", - "xpack.security.account.pageTitle": "{strongUsername}の設定", "xpack.security.account.passwordLengthDescription": "パスワードが短すぎます。", "xpack.security.account.passwordsDoNotMatch": "パスワードが一致していません。", "xpack.security.account.usernameGroupDescription": "この情報は変更できません。", @@ -23333,22 +23332,6 @@ "xpack.security.management.roles.statusColumnName": "ステータス", "xpack.security.management.roles.subtitle": "ユーザーのグループにロールを適用してスタック全体のパーミッションを管理します。", "xpack.security.management.rolesTitle": "ロール", - "xpack.security.management.users.changePasswordFlyout.confirmButton": "{isSubmitting, select, true{パスワードを変更しています…} other{パスワードの変更}}", - "xpack.security.management.users.changePasswordFlyout.confirmPasswordInvalidError": "パスワードが一致していません。", - "xpack.security.management.users.changePasswordFlyout.confirmPasswordLabel": "パスワードの確認", - "xpack.security.management.users.changePasswordFlyout.confirmSystemPasswordButton": "{isSubmitting, select, true{パスワードを変更しています…} other{パスワードの変更}}", - "xpack.security.management.users.changePasswordFlyout.currentPasswordInvalidError": "無効なパスワードです。", - "xpack.security.management.users.changePasswordFlyout.currentPasswordLabel": "現在のパスワード", - "xpack.security.management.users.changePasswordFlyout.currentPasswordRequiredError": "現在のパスワードを入力してください。", - "xpack.security.management.users.changePasswordFlyout.errorMessage": "パスワードを変更できませんでした", - "xpack.security.management.users.changePasswordFlyout.passwordInvalidError": "パスワードは6文字以上でなければなりません。", - "xpack.security.management.users.changePasswordFlyout.passwordLabel": "新しいパスワード", - "xpack.security.management.users.changePasswordFlyout.passwordRequiredError": "新しいパスワードを入力してください。", - "xpack.security.management.users.changePasswordFlyout.successMessage": "'{username}'のパスワードが変更されました。", - "xpack.security.management.users.changePasswordFlyout.systemUserDescription": "変更後は、新しいパスワードを使用して手動で構成ファイルを更新し、Kibanaを再起動する必要があります。", - "xpack.security.management.users.changePasswordFlyout.systemUserTitle": "これは非常に重要です。", - "xpack.security.management.users.changePasswordFlyout.systemUserWarning": "このパスワードを変更すると、KibanaはElasticsearchと通信できません。", - "xpack.security.management.users.changePasswordFlyout.title": "パスワードを変更", "xpack.security.management.users.changePasswordFlyout.userLabel": "ユーザー", "xpack.security.management.users.confirmDelete.cancelButtonLabel": "キャンセル", "xpack.security.management.users.confirmDelete.cannotUndoWarning": "削除したユーザーは復元できません。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 990a113fcd9d6..a3482588bb262 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -5480,10 +5480,10 @@ "sharedUXComponents.noDataPage.elasticAgentCard.noPermission.description": "尚未启用此集成。您的管理员具有打开它所需的权限。", "sharedUXComponents.noDataPage.elasticAgentCard.noPermission.title": "请联系您的管理员", "sharedUXComponents.noDataPage.elasticAgentCard.title": "添加 Elastic 代理", - "sharedUXPackages.noDataViewsPrompt.learnMore": "希望了解详情?", - "sharedUXPackages.noDataViewsPrompt.readDocumentation": "阅读文档", "sharedUXComponents.pageTemplate.noDataCard.description": "继续,而不收集数据", "sharedUXComponents.toolbar.buttons.addFromLibrary.libraryButtonLabel": "从库中添加", + "sharedUXPackages.noDataViewsPrompt.learnMore": "希望了解详情?", + "sharedUXPackages.noDataViewsPrompt.readDocumentation": "阅读文档", "telemetry.callout.appliesSettingTitle": "对此设置的更改将应用到{allOfKibanaText} 且会自动保存。", "telemetry.callout.appliesSettingTitle.allOfKibanaText": "整个 Kibana", "telemetry.callout.clusterStatisticsDescription": "这是我们将收集的基本集群统计信息的示例。其包括索引、分片和节点的数目。还包括概括性的使用情况统计信息,例如监测是否打开。", @@ -22953,7 +22953,6 @@ "xpack.security.account.changePasswordTitle": "密码", "xpack.security.account.currentPasswordRequired": "当前密码必填。", "xpack.security.account.noEmailMessage": "没有电子邮件地址", - "xpack.security.account.pageTitle": "{strongUsername} 的设置", "xpack.security.account.passwordLengthDescription": "密码过短。", "xpack.security.account.passwordsDoNotMatch": "密码不匹配。", "xpack.security.account.usernameGroupDescription": "不能更改此信息。", @@ -23365,22 +23364,6 @@ "xpack.security.management.roles.statusColumnName": "状态", "xpack.security.management.roles.subtitle": "将角色应用到用户组并管理整个堆栈的权限。", "xpack.security.management.rolesTitle": "角色", - "xpack.security.management.users.changePasswordFlyout.confirmButton": "{isSubmitting, select, true{正在更改密码……} other{更改密码}}", - "xpack.security.management.users.changePasswordFlyout.confirmPasswordInvalidError": "密码不匹配。", - "xpack.security.management.users.changePasswordFlyout.confirmPasswordLabel": "确认密码", - "xpack.security.management.users.changePasswordFlyout.confirmSystemPasswordButton": "{isSubmitting, select, true{正在更改密码……} other{更改密码}}", - "xpack.security.management.users.changePasswordFlyout.currentPasswordInvalidError": "密码无效。", - "xpack.security.management.users.changePasswordFlyout.currentPasswordLabel": "当前密码", - "xpack.security.management.users.changePasswordFlyout.currentPasswordRequiredError": "输入您的当前密码。", - "xpack.security.management.users.changePasswordFlyout.errorMessage": "无法更改密码", - "xpack.security.management.users.changePasswordFlyout.passwordInvalidError": "密码长度必须至少为 6 个字符。", - "xpack.security.management.users.changePasswordFlyout.passwordLabel": "新密码", - "xpack.security.management.users.changePasswordFlyout.passwordRequiredError": "输入新密码。", - "xpack.security.management.users.changePasswordFlyout.successMessage": "已为“{username}”更改密码。", - "xpack.security.management.users.changePasswordFlyout.systemUserDescription": "更改后,必须使用新密码手动更新您的配置文件,然后重新启动 Kibana。", - "xpack.security.management.users.changePasswordFlyout.systemUserTitle": "这极其重要!", - "xpack.security.management.users.changePasswordFlyout.systemUserWarning": "更改此密码将会阻止 Kibana 与 Elasticsearch 通信。", - "xpack.security.management.users.changePasswordFlyout.title": "更改密码", "xpack.security.management.users.changePasswordFlyout.userLabel": "用户", "xpack.security.management.users.confirmDelete.cancelButtonLabel": "取消", "xpack.security.management.users.confirmDelete.cannotUndoWarning": "您无法恢复删除的用户。", diff --git a/x-pack/test/accessibility/apps/users.ts b/x-pack/test/accessibility/apps/users.ts index 5833a19580c24..71ed3a27c1073 100644 --- a/x-pack/test/accessibility/apps/users.ts +++ b/x-pack/test/accessibility/apps/users.ts @@ -98,7 +98,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.settings.clickLinkText('deleteA11y'); await find.clickByButtonText('Change password'); await a11y.testAppSnapshot(); - await testSubjects.click('formFlyoutCancelButton'); + await testSubjects.click('changePasswordFormCancelButton'); }); it('a11y test for deactivate user screen', async () => { diff --git a/x-pack/test/functional/apps/security/user_email.ts b/x-pack/test/functional/apps/security/user_email.ts index 65bf111ceedbf..b59f1ecee7d76 100644 --- a/x-pack/test/functional/apps/security/user_email.ts +++ b/x-pack/test/functional/apps/security/user_email.ts @@ -43,18 +43,18 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('login as new user and verify email', async function () { await PageObjects.security.login('newuser', 'changeme'); - await PageObjects.accountSetting.verifyAccountSettings('newuser@myEmail.com', 'newuser'); + await PageObjects.accountSetting.verifyAccountSettings('newuser'); }); it('click changepassword link, change the password and re-login', async function () { - await PageObjects.accountSetting.verifyAccountSettings('newuser@myEmail.com', 'newuser'); + await PageObjects.accountSetting.verifyAccountSettings('newuser'); await PageObjects.accountSetting.changePassword('changeme', 'mechange'); await PageObjects.security.forceLogout(); }); it('login as new user with changed password', async function () { await PageObjects.security.login('newuser', 'mechange'); - await PageObjects.accountSetting.verifyAccountSettings('newuser@myEmail.com', 'newuser'); + await PageObjects.accountSetting.verifyAccountSettings('newuser'); }); after(async function () { diff --git a/x-pack/test/functional/apps/security/users.ts b/x-pack/test/functional/apps/security/users.ts index 8448750bf1ccd..444e52a79602f 100644 --- a/x-pack/test/functional/apps/security/users.ts +++ b/x-pack/test/functional/apps/security/users.ts @@ -175,9 +175,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { return toastCount >= 1; }); const successToast = await toasts.getToastElement(1); - expect(await successToast.getVisibleText()).to.be( - `Password changed for '${optionalUser.username}'.` - ); + expect(await successToast.getVisibleText()).to.be('Password successfully changed.'); }); it('of current user when submitting form', async () => { @@ -196,9 +194,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { return toastCount >= 1; }); const successToast = await toasts.getToastElement(1); - expect(await successToast.getVisibleText()).to.be( - `Password changed for '${optionalUser.username}'.` - ); + expect(await successToast.getVisibleText()).to.be('Password successfully changed.'); }); }); diff --git a/x-pack/test/functional/page_objects/account_settings_page.ts b/x-pack/test/functional/page_objects/account_settings_page.ts index 5bf5be9030b75..85f644e8d42b9 100644 --- a/x-pack/test/functional/page_objects/account_settings_page.ts +++ b/x-pack/test/functional/page_objects/account_settings_page.ts @@ -9,27 +9,39 @@ import expect from '@kbn/expect'; import { FtrService } from '../ftr_provider_context'; export class AccountSettingsPageObject extends FtrService { + private readonly find = this.ctx.getService('find'); private readonly testSubjects = this.ctx.getService('testSubjects'); private readonly userMenu = this.ctx.getService('userMenu'); - async verifyAccountSettings(expectedEmail: string, expectedUserName: string) { + async verifyAccountSettings(expectedUserName: string) { await this.userMenu.clickProvileLink(); const usernameField = await this.testSubjects.find('username'); const userName = await usernameField.getVisibleText(); expect(userName).to.be(expectedUserName); - const emailIdField = await this.testSubjects.find('email'); - const emailField = await emailIdField.getVisibleText(); - expect(emailField).to.be(expectedEmail); await this.userMenu.closeMenu(); } async changePassword(currentPassword: string, newPassword: string) { - await this.testSubjects.setValue('currentPassword', currentPassword); - await this.testSubjects.setValue('newPassword', newPassword); - await this.testSubjects.setValue('confirmNewPassword', newPassword); - await this.testSubjects.click('changePasswordButton'); - await this.testSubjects.existOrFail('passwordUpdateSuccess'); + await this.testSubjects.click('openChangePasswordForm'); + + const currentPasswordInput = await this.find.byName('current_password'); + await currentPasswordInput.clearValue(); + await currentPasswordInput.type(currentPassword); + + const passwordInput = await this.find.byName('password'); + await passwordInput.clearValue(); + await passwordInput.type(newPassword); + + const confirmPasswordInput = await this.find.byName('confirm_password'); + await confirmPasswordInput.clearValue(); + await confirmPasswordInput.type(newPassword); + + await this.testSubjects.click('changePasswordFormSubmitButton'); + + const toast = await this.testSubjects.find('euiToastHeader'); + const title = await toast.getVisibleText(); + expect(title).to.contain('Password successfully changed'); } } diff --git a/x-pack/test/functional/page_objects/security_page.ts b/x-pack/test/functional/page_objects/security_page.ts index 508fb7106948a..3f4dc6056d8d2 100644 --- a/x-pack/test/functional/page_objects/security_page.ts +++ b/x-pack/test/functional/page_objects/security_page.ts @@ -497,7 +497,7 @@ export class SecurityPageObject extends FtrService { 'editUserChangePasswordConfirmPasswordInput', user.confirm_password ?? '' ); - await this.testSubjects.click('formFlyoutSubmitButton'); + await this.testSubjects.click('changePasswordFormSubmitButton'); } async updateUserProfile(user: UserFormValues) { diff --git a/yarn.lock b/yarn.lock index 35c60d9444f32..bd1dbe6f6fc54 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12280,7 +12280,7 @@ deep-object-diff@^1.1.0: resolved "https://registry.yarnpkg.com/deep-object-diff/-/deep-object-diff-1.1.0.tgz#d6fabf476c2ed1751fc94d5ca693d2ed8c18bc5a" integrity sha512-b+QLs5vHgS+IoSNcUE4n9HP2NwcHj7aqnJWsjPtuG75Rh5TOaGt0OjAYInh77d5T16V5cRDC+Pw/6ZZZiETBGw== -deepmerge@3.2.0, deepmerge@^4.0.0, deepmerge@^4.2.2: +deepmerge@3.2.0, deepmerge@^2.1.1, deepmerge@^4.0.0, deepmerge@^4.2.2: version "4.2.2" resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.2.2.tgz#44d2ea3679b8f4d4ffba33f03d865fc1e7bf4955" integrity sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg== @@ -14844,6 +14844,19 @@ formidable@^1.2.0: resolved "https://registry.yarnpkg.com/formidable/-/formidable-1.2.2.tgz#bf69aea2972982675f00865342b982986f6b8dd9" integrity sha512-V8gLm+41I/8kguQ4/o1D3RIHRmhYFG4pnNyonvua+40rqcEmT4+V71yaZ3B457xbbgCsCfjSPi65u/W6vK1U5Q== +formik@^2.2.9: + version "2.2.9" + resolved "https://registry.yarnpkg.com/formik/-/formik-2.2.9.tgz#8594ba9c5e2e5cf1f42c5704128e119fc46232d0" + integrity sha512-LQLcISMmf1r5at4/gyJigGn0gOwFbeEAlji+N9InZF6LIMXnFNkO42sCI8Jt84YZggpD4cPWObAZaxpEFtSzNA== + dependencies: + deepmerge "^2.1.1" + hoist-non-react-statics "^3.3.0" + lodash "^4.17.21" + lodash-es "^4.17.21" + react-fast-compare "^2.0.1" + tiny-warning "^1.0.2" + tslib "^1.10.0" + forwarded-parse@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/forwarded-parse/-/forwarded-parse-2.1.0.tgz#1ae9d7a4be3af884f74d936d856f7d8c6abd0439" @@ -19162,7 +19175,7 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" -lodash-es@^4.17.11: +lodash-es@^4.17.11, lodash-es@^4.17.21: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee" integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw== @@ -23637,7 +23650,7 @@ react-error-boundary@^3.1.0: dependencies: "@babel/runtime" "^7.12.5" -react-fast-compare@^2.0.4: +react-fast-compare@^2.0.1, react-fast-compare@^2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-2.0.4.tgz#e84b4d455b0fec113e0402c329352715196f81f9" integrity sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw== @@ -27600,7 +27613,7 @@ tiny-invariant@^1.0.2, tiny-invariant@^1.0.6: resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.0.6.tgz#b3f9b38835e36a41c843a3b0907a5a7b3755de73" integrity sha512-FOyLWWVjG+aC0UqG76V53yAWdXfH8bO6FNmyZOuUrzDzK8DI3/JRY25UD7+g49JWM1LXwymsKERB+DzI0dTEQA== -tiny-warning@^1.0.0, tiny-warning@^1.0.3: +tiny-warning@^1.0.0, tiny-warning@^1.0.2, tiny-warning@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754" integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA== From fc30ec9508b2a0446677b7ded49057d4ec94289e Mon Sep 17 00:00:00 2001 From: Thom Heymann Date: Tue, 31 May 2022 20:41:35 +0100 Subject: [PATCH 06/13] design feedback --- .../user_profile/user_profile.tsx | 89 ++++++++++--------- .../users/edit_user/change_password_modal.tsx | 2 +- 2 files changed, 48 insertions(+), 43 deletions(-) diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx index c5d8f852ac183..8f16ec71f8aef 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx @@ -98,7 +98,7 @@ export const UserProfile: FunctionComponent = ({ user, data }) defaultMessage="Username" /> ), - description: user.username, + description: user.username as string | undefined, helpText: ( = ({ user, data }) ]; if (!canChangeDetails) { - if (user.full_name) { - rightSideItems.push({ - title: ( - - ), - description: user.full_name, - helpText: ( - - ), - testSubj: 'full_name', - }); - } + rightSideItems.push({ + title: ( + + ), + description: user.full_name, + helpText: ( + + ), + testSubj: 'full_name', + }); - if (user.email) { - rightSideItems.push({ - title: ( - - ), - description: user.email, - helpText: ( - - ), - testSubj: 'email', - }); - } + rightSideItems.push({ + title: ( + + ), + description: user.email, + helpText: ( + + ), + testSubj: 'email', + }); } return ( @@ -167,10 +163,8 @@ export const UserProfile: FunctionComponent = ({ user, data }) = ({ user, data }) ), - description: {item.description}, + description: ( + + {item.description || ( + + + + )} + + ), }, ]} compressed @@ -202,7 +207,7 @@ export const UserProfile: FunctionComponent = ({ user, data }) )), }} bottomBar={formChanges.count > 0 ? : null} - bottomBarProps={{ paddingSize: 'm' }} + bottomBarProps={{ paddingSize: 'm', position: 'fixed' }} restrictWidth={1000} >
@@ -650,7 +655,7 @@ export const SaveChangesBottomBar: FunctionComponent = () => { const { count } = useFormChangesContext(); return ( - + diff --git a/x-pack/plugins/security/public/management/users/edit_user/change_password_modal.tsx b/x-pack/plugins/security/public/management/users/edit_user/change_password_modal.tsx index 8b8ca9f393917..beba64e6b2fb9 100644 --- a/x-pack/plugins/security/public/management/users/edit_user/change_password_modal.tsx +++ b/x-pack/plugins/security/public/management/users/edit_user/change_password_modal.tsx @@ -112,7 +112,7 @@ export const ChangePasswordModal: FunctionComponent = ); services.notifications!.toasts.addSuccess( i18n.translate('xpack.security.management.users.changePasswordForm.successMessage', { - defaultMessage: 'Password successfully changed.', + defaultMessage: 'Password successfully changed', }) ); onSuccess?.(); From fb6ab093cc63c3d7650ac0b77977dfe630deb90b Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Wed, 1 Jun 2022 11:11:17 +0200 Subject: [PATCH 07/13] Fix tests. --- x-pack/test/functional/apps/security/users.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/test/functional/apps/security/users.ts b/x-pack/test/functional/apps/security/users.ts index 444e52a79602f..d017623edf8b1 100644 --- a/x-pack/test/functional/apps/security/users.ts +++ b/x-pack/test/functional/apps/security/users.ts @@ -175,7 +175,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { return toastCount >= 1; }); const successToast = await toasts.getToastElement(1); - expect(await successToast.getVisibleText()).to.be('Password successfully changed.'); + expect(await successToast.getVisibleText()).to.be('Password successfully changed'); }); it('of current user when submitting form', async () => { @@ -194,7 +194,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { return toastCount >= 1; }); const successToast = await toasts.getToastElement(1); - expect(await successToast.getVisibleText()).to.be('Password successfully changed.'); + expect(await successToast.getVisibleText()).to.be('Password successfully changed'); }); }); From 705a7d8f99f831b3649d253ba47657d21a97e09a Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Wed, 1 Jun 2022 12:22:24 +0200 Subject: [PATCH 08/13] Prevent cloud users from updating their user profile (#132622) --- x-pack/plugins/cloud/public/plugin.test.ts | 7 +- x-pack/plugins/cloud/public/plugin.tsx | 8 +- x-pack/plugins/cloud/server/plugin.test.ts | 70 ++++++++ x-pack/plugins/cloud/server/plugin.ts | 4 + .../common/model/authenticated_user.mock.ts | 1 + .../common/model/authenticated_user.ts | 5 + .../nav_control_component.test.tsx | 1 + .../authentication_service.test.ts | 2 + .../authentication/authentication_service.ts | 3 + .../authentication/authenticator.test.ts | 1 + .../server/authentication/authenticator.ts | 2 + .../authentication/providers/base.mock.ts | 1 + .../server/authentication/providers/base.ts | 10 ++ .../authentication/providers/saml.test.ts | 38 +++++ .../security/server/elasticsearch/index.ts | 5 +- x-pack/plugins/security/server/mocks.ts | 1 + x-pack/plugins/security/server/plugin.test.ts | 9 ++ x-pack/plugins/security/server/plugin.ts | 23 +++ .../security/server/routes/index.mock.ts | 2 + .../server/routes/user_profile/update.test.ts | 150 ++++++++++++++++++ .../server/routes/user_profile/update.ts | 11 ++ .../user_profile/user_profile_service.test.ts | 5 + .../apis/security/basic_login.js | 2 + .../tests/http_bearer/header.ts | 1 + .../tests/kerberos/kerberos_login.ts | 1 + .../login_selector/basic_functionality.ts | 1 + .../oidc/authorization_code_flow/oidc_auth.ts | 2 + .../tests/oidc/implicit_flow/oidc_auth.ts | 1 + .../tests/pki/pki_auth.ts | 2 + .../tests/saml/saml_login.ts | 1 + 30 files changed, 358 insertions(+), 12 deletions(-) create mode 100644 x-pack/plugins/cloud/server/plugin.test.ts create mode 100644 x-pack/plugins/security/server/routes/user_profile/update.test.ts diff --git a/x-pack/plugins/cloud/public/plugin.test.ts b/x-pack/plugins/cloud/public/plugin.test.ts index 36be9e590f216..f48195dbc83f0 100644 --- a/x-pack/plugins/cloud/public/plugin.test.ts +++ b/x-pack/plugins/cloud/public/plugin.test.ts @@ -169,13 +169,10 @@ describe('Cloud Plugin', () => { expect(hashId1).not.toEqual(hashId2); }); - test('user hash does not include cloudId when authenticated via Cloud SAML', async () => { + test('user hash does not include cloudId when user is an Elastic Cloud user', async () => { const { coreSetup } = await setupPlugin({ config: { id: 'cloudDeploymentId' }, - currentUserProps: { - username, - authentication_realm: { type: 'saml', name: 'cloud-saml-kibana' }, - }, + currentUserProps: { username, elastic_cloud_user: true }, }); expect(coreSetup.analytics.registerContextProvider).toHaveBeenCalled(); diff --git a/x-pack/plugins/cloud/public/plugin.tsx b/x-pack/plugins/cloud/public/plugin.tsx index 1bccf219225dc..c9c0cb1c4933c 100644 --- a/x-pack/plugins/cloud/public/plugin.tsx +++ b/x-pack/plugins/cloud/public/plugin.tsx @@ -262,12 +262,8 @@ export class CloudPlugin implements Plugin { name: 'cloud_user_id', context$: from(security.authc.getCurrentUser()).pipe( map((user) => { - if ( - getIsCloudEnabled(cloudId) && - user.authentication_realm?.type === 'saml' && - user.authentication_realm?.name === 'cloud-saml-kibana' - ) { - // If authenticated via Cloud SAML, use the SAML username as the user ID + if (user.elastic_cloud_user) { + // If authenticated via Elastic Cloud SSO, use the username as the user ID return user.username; } diff --git a/x-pack/plugins/cloud/server/plugin.test.ts b/x-pack/plugins/cloud/server/plugin.test.ts new file mode 100644 index 0000000000000..ccb0b8545fcf6 --- /dev/null +++ b/x-pack/plugins/cloud/server/plugin.test.ts @@ -0,0 +1,70 @@ +/* + * 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 { coreMock } from '@kbn/core/server/mocks'; +import { CloudPlugin } from './plugin'; +import { config } from './config'; +import { securityMock } from '@kbn/security-plugin/server/mocks'; +import { usageCollectionPluginMock } from '@kbn/usage-collection-plugin/server/mocks'; + +describe('Cloud Plugin', () => { + describe('#setup', () => { + describe('setupSecurity', () => { + it('properly handles missing optional Security dependency if Cloud ID is NOT set.', async () => { + const plugin = new CloudPlugin( + coreMock.createPluginInitializerContext(config.schema.validate({})) + ); + + expect(() => + plugin.setup(coreMock.createSetup(), { + usageCollection: usageCollectionPluginMock.createSetupContract(), + }) + ).not.toThrow(); + }); + + it('properly handles missing optional Security dependency if Cloud ID is set.', async () => { + const plugin = new CloudPlugin( + coreMock.createPluginInitializerContext(config.schema.validate({ id: 'my-cloud' })) + ); + + expect(() => + plugin.setup(coreMock.createSetup(), { + usageCollection: usageCollectionPluginMock.createSetupContract(), + }) + ).not.toThrow(); + }); + + it('does not notify Security plugin about Cloud environment if Cloud ID is NOT set.', async () => { + const plugin = new CloudPlugin( + coreMock.createPluginInitializerContext(config.schema.validate({})) + ); + + const securityDependencyMock = securityMock.createSetup(); + plugin.setup(coreMock.createSetup(), { + security: securityDependencyMock, + usageCollection: usageCollectionPluginMock.createSetupContract(), + }); + + expect(securityDependencyMock.setIsElasticCloudDeployment).not.toHaveBeenCalled(); + }); + + it('properly notifies Security plugin about Cloud environment if Cloud ID is set.', async () => { + const plugin = new CloudPlugin( + coreMock.createPluginInitializerContext(config.schema.validate({ id: 'my-cloud' })) + ); + + const securityDependencyMock = securityMock.createSetup(); + plugin.setup(coreMock.createSetup(), { + security: securityDependencyMock, + usageCollection: usageCollectionPluginMock.createSetupContract(), + }); + + expect(securityDependencyMock.setIsElasticCloudDeployment).toHaveBeenCalledTimes(1); + }); + }); + }); +}); diff --git a/x-pack/plugins/cloud/server/plugin.ts b/x-pack/plugins/cloud/server/plugin.ts index 2cbb41531ecf5..8d5c38477d0cb 100644 --- a/x-pack/plugins/cloud/server/plugin.ts +++ b/x-pack/plugins/cloud/server/plugin.ts @@ -50,6 +50,10 @@ export class CloudPlugin implements Plugin { registerCloudDeploymentIdAnalyticsContext(core.analytics, this.config.id); registerCloudUsageCollector(usageCollection, { isCloudEnabled }); + if (isCloudEnabled) { + security?.setIsElasticCloudDeployment(); + } + if (this.config.full_story.enabled) { registerFullstoryRoute({ httpResources: core.http.resources, diff --git a/x-pack/plugins/security/common/model/authenticated_user.mock.ts b/x-pack/plugins/security/common/model/authenticated_user.mock.ts index cb7d64fe79786..73641d2fa5983 100644 --- a/x-pack/plugins/security/common/model/authenticated_user.mock.ts +++ b/x-pack/plugins/security/common/model/authenticated_user.mock.ts @@ -23,6 +23,7 @@ export function mockAuthenticatedUser(user: MockAuthenticatedUserProps = {}) { lookup_realm: { name: 'native1', type: 'native' }, authentication_provider: { type: 'basic', name: 'basic1' }, authentication_type: 'realm', + elastic_cloud_user: false, metadata: { _reserved: false }, ...user, }; diff --git a/x-pack/plugins/security/common/model/authenticated_user.ts b/x-pack/plugins/security/common/model/authenticated_user.ts index 708cb00fbca50..2237384791e8b 100644 --- a/x-pack/plugins/security/common/model/authenticated_user.ts +++ b/x-pack/plugins/security/common/model/authenticated_user.ts @@ -42,6 +42,11 @@ export interface AuthenticatedUser extends User { * @example "realm" | "api_key" | "token" | "anonymous" | "internal" */ authentication_type: string; + + /** + * Indicates whether user is authenticated via Elastic Cloud built-in SAML realm. + */ + elastic_cloud_user: boolean; } export function isUserAnonymous(user: Pick) { diff --git a/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx b/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx index 24d4594091d5f..20f293f37fc26 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx +++ b/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx @@ -83,6 +83,7 @@ describe('SecurityNavControl', () => { "type": "native", }, "authentication_type": "realm", + "elastic_cloud_user": false, "email": "email", "enabled": true, "full_name": "full name", diff --git a/x-pack/plugins/security/server/authentication/authentication_service.test.ts b/x-pack/plugins/security/server/authentication/authentication_service.test.ts index ce4cef5ee6cf7..fc69971649330 100644 --- a/x-pack/plugins/security/server/authentication/authentication_service.test.ts +++ b/x-pack/plugins/security/server/authentication/authentication_service.test.ts @@ -74,6 +74,7 @@ describe('AuthenticationService', () => { session: jest.Mocked>; applicationName: 'kibana-.kibana'; kibanaFeatures: []; + isElasticCloudDeployment: jest.Mock; }; beforeEach(() => { logger = loggingSystemMock.createLogger(); @@ -115,6 +116,7 @@ describe('AuthenticationService', () => { userProfileService: userProfileServiceMock.createStart(), applicationName: 'kibana-.kibana', kibanaFeatures: [], + isElasticCloudDeployment: jest.fn().mockReturnValue(false), }; (mockStartAuthenticationParams.http.basePath.get as jest.Mock).mockImplementation( () => mockStartAuthenticationParams.http.basePath.serverBasePath diff --git a/x-pack/plugins/security/server/authentication/authentication_service.ts b/x-pack/plugins/security/server/authentication/authentication_service.ts index 1ab3eaccef203..50ad8ae2b082b 100644 --- a/x-pack/plugins/security/server/authentication/authentication_service.ts +++ b/x-pack/plugins/security/server/authentication/authentication_service.ts @@ -54,6 +54,7 @@ interface AuthenticationServiceStartParams { loggers: LoggerFactory; applicationName: string; kibanaFeatures: KibanaFeature[]; + isElasticCloudDeployment: () => boolean; } export interface InternalAuthenticationServiceStart extends AuthenticationServiceStart { @@ -304,6 +305,7 @@ export class AuthenticationService { session, applicationName, kibanaFeatures, + isElasticCloudDeployment, }: AuthenticationServiceStartParams): InternalAuthenticationServiceStart { const apiKeys = new APIKeys({ clusterClient, @@ -340,6 +342,7 @@ export class AuthenticationService { getServerBaseURL, license: this.license, session, + isElasticCloudDeployment, }); return { diff --git a/x-pack/plugins/security/server/authentication/authenticator.test.ts b/x-pack/plugins/security/server/authentication/authenticator.test.ts index 0959479e8023f..43a85603edbb9 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.test.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.test.ts @@ -72,6 +72,7 @@ function getMockOptions({ session: sessionMock.create(), featureUsageService: securityFeatureUsageServiceMock.createStartContract(), userProfileService: userProfileServiceMock.createStart(), + isElasticCloudDeployment: jest.fn().mockReturnValue(false), }; } diff --git a/x-pack/plugins/security/server/authentication/authenticator.ts b/x-pack/plugins/security/server/authentication/authenticator.ts index a813c1ca80d6c..7f42bde1e397d 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.ts @@ -89,6 +89,7 @@ export interface AuthenticatorOptions { clusterClient: IClusterClient; session: PublicMethodsOf; getServerBaseURL: () => string; + isElasticCloudDeployment: () => boolean; } /** @internal */ @@ -232,6 +233,7 @@ export class Authenticator { logger: this.options.loggers.get('tokens'), }), getServerBaseURL: this.options.getServerBaseURL, + isElasticCloudDeployment: this.options.isElasticCloudDeployment, }; this.providers = new Map( diff --git a/x-pack/plugins/security/server/authentication/providers/base.mock.ts b/x-pack/plugins/security/server/authentication/providers/base.mock.ts index 2abe3cf6277a5..af7537ca15074 100644 --- a/x-pack/plugins/security/server/authentication/providers/base.mock.ts +++ b/x-pack/plugins/security/server/authentication/providers/base.mock.ts @@ -27,5 +27,6 @@ export function mockAuthenticationProviderOptions(options?: { name: string }) { urls: { loggedOut: jest.fn().mockReturnValue('/mock-server-basepath/security/logged_out'), }, + isElasticCloudDeployment: jest.fn().mockReturnValue(false), }; } diff --git a/x-pack/plugins/security/server/authentication/providers/base.ts b/x-pack/plugins/security/server/authentication/providers/base.ts index a344243ba97a7..ccf9ecba71f36 100644 --- a/x-pack/plugins/security/server/authentication/providers/base.ts +++ b/x-pack/plugins/security/server/authentication/providers/base.ts @@ -38,6 +38,7 @@ export interface AuthenticationProviderOptions { urls: { loggedOut: (request: KibanaRequest) => string; }; + isElasticCloudDeployment: () => boolean; } /** @@ -45,6 +46,11 @@ export interface AuthenticationProviderOptions { */ export type AuthenticationProviderSpecificOptions = Record; +/** + * Name of the Elastic Cloud built-in SSO realm. + */ +export const ELASTIC_CLOUD_SSO_REALM_NAME = 'cloud-saml-kibana'; + /** * Base class that all authentication providers should extend. */ @@ -133,6 +139,10 @@ export abstract class BaseAuthenticationProvider { return deepFreeze({ ...authenticationInfo, authentication_provider: { type: this.type, name: this.options.name }, + elastic_cloud_user: + this.options.isElasticCloudDeployment() && + authenticationInfo.authentication_realm.type === 'saml' && + authenticationInfo.authentication_realm.name === ELASTIC_CLOUD_SSO_REALM_NAME, } as AuthenticatedUser); } } diff --git a/x-pack/plugins/security/server/authentication/providers/saml.test.ts b/x-pack/plugins/security/server/authentication/providers/saml.test.ts index 3fe22ee621065..a165b1960c3f3 100644 --- a/x-pack/plugins/security/server/authentication/providers/saml.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/saml.test.ts @@ -18,6 +18,7 @@ import { mockAuthenticatedUser } from '../../../common/model/authenticated_user. import { securityMock } from '../../mocks'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; +import { ELASTIC_CLOUD_SSO_REALM_NAME } from './base'; import type { MockAuthenticationProviderOptions } from './base.mock'; import { mockAuthenticationProviderOptions } from './base.mock'; import { SAMLAuthenticationProvider, SAMLLogin } from './saml'; @@ -366,6 +367,43 @@ describe('SAMLAuthenticationProvider', () => { ); }); + it('recognizes Elastic Cloud users.', async () => { + const nonElasticCloudUser = mockAuthenticatedUser({ + authentication_provider: { type: 'saml', name: 'saml' }, + authentication_realm: { type: 'saml', name: 'random-saml' }, + }); + const elasticCloudUser = mockAuthenticatedUser({ + authentication_provider: { type: 'saml', name: 'saml' }, + authentication_realm: { type: 'saml', name: ELASTIC_CLOUD_SSO_REALM_NAME }, + }); + + // The only case when user should be recognized as Elastic Cloud user: Kibana is running inside Cloud + // deployment and user is authenticated with SAML realm of the predefined name. + for (const [authentication, isElasticCloudDeployment, isElasticCloudUser] of [ + [nonElasticCloudUser, false, false], + [nonElasticCloudUser, true, false], + [elasticCloudUser, false, false], + [elasticCloudUser, true, true], + ]) { + mockOptions.client.asInternalUser.transport.request.mockResolvedValue({ + username: 'user', + access_token: 'valid-token', + refresh_token: 'valid-refresh-token', + realm: 'test-realm', + authentication, + }); + + mockOptions.isElasticCloudDeployment.mockReturnValue(isElasticCloudDeployment); + + const loginResult = await provider.login( + httpServerMock.createKibanaRequest({ headers: {} }), + { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml' } + ); + + expect(loginResult.user?.elastic_cloud_user).toBe(isElasticCloudUser); + } + }); + it('redirects to the home page if `relayState` includes external URL', async () => { await expect( provider.login(httpServerMock.createKibanaRequest({ headers: {} }), { diff --git a/x-pack/plugins/security/server/elasticsearch/index.ts b/x-pack/plugins/security/server/elasticsearch/index.ts index 46acb874f7fc2..239802028b122 100644 --- a/x-pack/plugins/security/server/elasticsearch/index.ts +++ b/x-pack/plugins/security/server/elasticsearch/index.ts @@ -7,7 +7,10 @@ import type { AuthenticatedUser } from '../../common/model'; -export type AuthenticationInfo = Omit; +export type AuthenticationInfo = Omit< + AuthenticatedUser, + 'authentication_provider' | 'elastic_cloud_user' +>; export type { ElasticsearchServiceStart, OnlineStatusRetryScheduler, diff --git a/x-pack/plugins/security/server/mocks.ts b/x-pack/plugins/security/server/mocks.ts index de484647ffb6d..d126efb4d79bd 100644 --- a/x-pack/plugins/security/server/mocks.ts +++ b/x-pack/plugins/security/server/mocks.ts @@ -31,6 +31,7 @@ function createSetupMock() { privilegeDeprecationsService: { getKibanaRolesByFeatureId: jest.fn(), }, + setIsElasticCloudDeployment: jest.fn(), }; } diff --git a/x-pack/plugins/security/server/plugin.test.ts b/x-pack/plugins/security/server/plugin.test.ts index c1fddb1751189..663f0f16ea260 100644 --- a/x-pack/plugins/security/server/plugin.test.ts +++ b/x-pack/plugins/security/server/plugin.test.ts @@ -124,9 +124,18 @@ describe('Security Plugin', () => { "privilegeDeprecationsService": Object { "getKibanaRolesByFeatureId": [Function], }, + "setIsElasticCloudDeployment": [Function], } `); }); + + it('#setIsElasticCloudDeployment cannot be called twice', () => { + const { setIsElasticCloudDeployment } = plugin.setup(mockCoreSetup, mockSetupDependencies); + setIsElasticCloudDeployment(); + expect(() => setIsElasticCloudDeployment()).toThrowErrorMatchingInlineSnapshot( + `"The Elastic Cloud deployment flag has been set already!"` + ); + }); }); describe('start()', () => { diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index fb476168d4cbe..c470147acb957 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -92,6 +92,12 @@ export interface SecurityPluginSetup { * Exposes services to access kibana roles per feature id with the GetDeprecationsContext */ privilegeDeprecationsService: PrivilegeDeprecationsService; + + /** + * Sets the flag to indicate that Kibana is running inside an Elastic Cloud deployment. This flag is supposed to be + * set by the Cloud plugin and can be only once. + */ + setIsElasticCloudDeployment: () => void; } /** @@ -199,6 +205,21 @@ export class SecurityPlugin return this.userProfileStart; }; + /** + * Indicates whether Kibana is running inside an Elastic Cloud deployment. Since circular plugin dependencies are + * forbidden, this flag is supposed to be set by the Cloud plugin that already depends on the Security plugin. + * @private + */ + private isElasticCloudDeployment?: boolean; + private readonly getIsElasticCloudDeployment = () => this.isElasticCloudDeployment === true; + private readonly setIsElasticCloudDeployment = () => { + if (this.isElasticCloudDeployment !== undefined) { + throw new Error(`The Elastic Cloud deployment flag has been set already!`); + } + + this.isElasticCloudDeployment = true; + }; + constructor(private readonly initializerContext: PluginInitializerContext) { this.logger = this.initializerContext.logger.get(); @@ -348,6 +369,7 @@ export class SecurityPlugin license, logger: this.logger.get('deprecations'), }), + setIsElasticCloudDeployment: this.setIsElasticCloudDeployment, }); } @@ -386,6 +408,7 @@ export class SecurityPlugin session, applicationName: this.authorizationSetup!.applicationName, kibanaFeatures: features.getKibanaFeatures(), + isElasticCloudDeployment: this.getIsElasticCloudDeployment, }); this.authorizationService.start({ diff --git a/x-pack/plugins/security/server/routes/index.mock.ts b/x-pack/plugins/security/server/routes/index.mock.ts index 3cda2a0ec9bc5..21bbd47db20b5 100644 --- a/x-pack/plugins/security/server/routes/index.mock.ts +++ b/x-pack/plugins/security/server/routes/index.mock.ts @@ -23,6 +23,7 @@ import { authorizationMock } from '../authorization/index.mock'; import { ConfigSchema, createConfig } from '../config'; import { sessionMock } from '../session_management/session.mock'; import type { SecurityRequestHandlerContext } from '../types'; +import { userProfileServiceMock } from '../user_profile/user_profile_service.mock'; export const routeDefinitionParamsMock = { create: (rawConfig: Record = {}) => { @@ -46,6 +47,7 @@ export const routeDefinitionParamsMock = { getSession: jest.fn().mockReturnValue(sessionMock.create()), getAuthenticationService: jest.fn().mockReturnValue(authenticationServiceMock.createStart()), getAnonymousAccessService: jest.fn(), + getUserProfileService: jest.fn().mockReturnValue(userProfileServiceMock.createStart()), } as unknown as DeeplyMockedKeys; }, }; diff --git a/x-pack/plugins/security/server/routes/user_profile/update.test.ts b/x-pack/plugins/security/server/routes/user_profile/update.test.ts new file mode 100644 index 0000000000000..a842194a7eba3 --- /dev/null +++ b/x-pack/plugins/security/server/routes/user_profile/update.test.ts @@ -0,0 +1,150 @@ +/* + * 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 type { ObjectType } from '@kbn/config-schema'; +import type { RequestHandler, RouteConfig } from '@kbn/core/server'; +import { kibanaResponseFactory } from '@kbn/core/server'; +import { httpServerMock } from '@kbn/core/server/mocks'; +import type { PublicMethodsOf } from '@kbn/utility-types'; +import type { DeeplyMockedKeys } from '@kbn/utility-types/jest'; + +import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; +import type { InternalAuthenticationServiceStart } from '../../authentication'; +import { authenticationServiceMock } from '../../authentication/authentication_service.mock'; +import type { Session } from '../../session_management'; +import { sessionMock } from '../../session_management/session.mock'; +import type { SecurityRequestHandlerContext, SecurityRouter } from '../../types'; +import type { UserProfileServiceStart } from '../../user_profile'; +import { userProfileServiceMock } from '../../user_profile/user_profile_service.mock'; +import { routeDefinitionParamsMock } from '../index.mock'; +import { defineUpdateUserProfileDataRoute } from './update'; + +function getMockContext() { + return { + licensing: { + license: { check: jest.fn().mockReturnValue({ check: 'valid' }) }, + }, + } as unknown as SecurityRequestHandlerContext; +} + +describe('Update profile routes', () => { + let router: jest.Mocked; + let session: jest.Mocked>; + let userProfileService: jest.Mocked; + let authc: DeeplyMockedKeys; + beforeEach(() => { + const routeParamsMock = routeDefinitionParamsMock.create(); + router = routeParamsMock.router; + + session = sessionMock.create(); + routeParamsMock.getSession.mockReturnValue(session); + + userProfileService = userProfileServiceMock.createStart(); + routeParamsMock.getUserProfileService.mockReturnValue(userProfileService); + + authc = authenticationServiceMock.createStart(); + routeParamsMock.getAuthenticationService.mockReturnValue(authc); + + defineUpdateUserProfileDataRoute(routeParamsMock); + }); + + describe('update profile data', () => { + let routeHandler: RequestHandler; + let routeConfig: RouteConfig; + beforeEach(() => { + const [updateRouteConfig, updateRouteHandler] = router.post.mock.calls.find( + ([{ path }]) => path === '/internal/security/user_profile/_data' + )!; + + routeConfig = updateRouteConfig; + routeHandler = updateRouteHandler; + }); + + it('correctly defines route.', () => { + const bodySchema = (routeConfig.validate as any).body as ObjectType; + expect(() => bodySchema.validate(0)).toThrowErrorMatchingInlineSnapshot( + `"expected value of type [object] but got [number]"` + ); + expect(() => bodySchema.validate('avatar')).toThrowErrorMatchingInlineSnapshot( + `"could not parse record value from json input"` + ); + expect(() => bodySchema.validate(true)).toThrowErrorMatchingInlineSnapshot( + `"expected value of type [object] but got [boolean]"` + ); + expect(() => bodySchema.validate(null)).toThrowErrorMatchingInlineSnapshot( + `"expected value of type [object] but got [null]"` + ); + expect(() => bodySchema.validate(undefined)).toThrowErrorMatchingInlineSnapshot( + `"expected value of type [object] but got [undefined]"` + ); + + expect(bodySchema.validate({})).toEqual({}); + expect( + bodySchema.validate({ title: 'some-title', content: { deepProperty: { type: 'basic' } } }) + ).toEqual({ title: 'some-title', content: { deepProperty: { type: 'basic' } } }); + }); + + it('fails if session is not found.', async () => { + session.get.mockResolvedValue(null); + + await expect( + routeHandler( + getMockContext(), + httpServerMock.createKibanaRequest({ body: {} }), + kibanaResponseFactory + ) + ).resolves.toEqual(expect.objectContaining({ status: 404 })); + + expect(userProfileService.update).not.toHaveBeenCalled(); + }); + + it('fails if session does not have profile ID.', async () => { + session.get.mockResolvedValue(sessionMock.createValue({ userProfileId: undefined })); + + await expect( + routeHandler( + getMockContext(), + httpServerMock.createKibanaRequest({ body: {} }), + kibanaResponseFactory + ) + ).resolves.toEqual(expect.objectContaining({ status: 404 })); + + expect(userProfileService.update).not.toHaveBeenCalled(); + }); + + it('fails for Elastic Cloud users.', async () => { + session.get.mockResolvedValue(sessionMock.createValue()); + authc.getCurrentUser.mockReturnValue(mockAuthenticatedUser({ elastic_cloud_user: true })); + + await expect( + routeHandler( + getMockContext(), + httpServerMock.createKibanaRequest({ body: {} }), + kibanaResponseFactory + ) + ).resolves.toEqual(expect.objectContaining({ status: 403 })); + + expect(userProfileService.update).not.toHaveBeenCalled(); + }); + + it('updates profile.', async () => { + session.get.mockResolvedValue(sessionMock.createValue({ userProfileId: 'u_some_id' })); + authc.getCurrentUser.mockReturnValue(mockAuthenticatedUser()); + + await expect( + routeHandler( + getMockContext(), + httpServerMock.createKibanaRequest({ body: { some: 'property' } }), + kibanaResponseFactory + ) + ).resolves.toEqual(expect.objectContaining({ status: 200, payload: undefined })); + + expect(userProfileService.update).toBeCalledTimes(1); + expect(userProfileService.update).toBeCalledWith('u_some_id', { some: 'property' }); + }); + }); +}); diff --git a/x-pack/plugins/security/server/routes/user_profile/update.ts b/x-pack/plugins/security/server/routes/user_profile/update.ts index 333821508e96e..d6f4726e4b7d5 100644 --- a/x-pack/plugins/security/server/routes/user_profile/update.ts +++ b/x-pack/plugins/security/server/routes/user_profile/update.ts @@ -16,6 +16,7 @@ export function defineUpdateUserProfileDataRoute({ getSession, getUserProfileService, logger, + getAuthenticationService, }: RouteDefinitionParams) { router.post( { @@ -36,6 +37,16 @@ export function defineUpdateUserProfileDataRoute({ return response.notFound(); } + const currentUser = getAuthenticationService().getCurrentUser(request); + if (currentUser?.elastic_cloud_user) { + logger.warn( + `Elastic Cloud SSO users aren't allowed to update profiles in Kibana. (sid: ${session.sid.slice( + -10 + )})` + ); + return response.forbidden(); + } + const userProfileService = getUserProfileService(); try { await userProfileService.update(session.userProfileId, request.body); diff --git a/x-pack/plugins/security/server/user_profile/user_profile_service.test.ts b/x-pack/plugins/security/server/user_profile/user_profile_service.test.ts index 2da6ec8de944b..a2646c3e957b0 100644 --- a/x-pack/plugins/security/server/user_profile/user_profile_service.test.ts +++ b/x-pack/plugins/security/server/user_profile/user_profile_service.test.ts @@ -79,6 +79,7 @@ describe('UserProfileService', () => { "type": "native", }, "authentication_type": "realm", + "elastic_cloud_user": false, "email": "email", "enabled": true, "full_name": "full name", @@ -129,6 +130,7 @@ describe('UserProfileService', () => { "type": "native", }, "authentication_type": "realm", + "elastic_cloud_user": false, "email": "email", "enabled": true, "full_name": "full name", @@ -215,6 +217,7 @@ describe('UserProfileService', () => { "type": "native", }, "authentication_type": "realm", + "elastic_cloud_user": false, "email": "email", "enabled": true, "full_name": "full name", @@ -259,6 +262,7 @@ describe('UserProfileService', () => { "type": "native", }, "authentication_type": "realm", + "elastic_cloud_user": false, "email": "email", "enabled": true, "full_name": "full name", @@ -340,6 +344,7 @@ describe('UserProfileService', () => { "type": "native", }, "authentication_type": "realm", + "elastic_cloud_user": false, "email": "email", "enabled": true, "full_name": "full name", diff --git a/x-pack/test/api_integration/apis/security/basic_login.js b/x-pack/test/api_integration/apis/security/basic_login.js index ea8971d620231..c81034b6fb824 100644 --- a/x-pack/test/api_integration/apis/security/basic_login.js +++ b/x-pack/test/api_integration/apis/security/basic_login.js @@ -146,6 +146,7 @@ export default function ({ getService }) { 'lookup_realm', 'authentication_provider', 'authentication_type', + 'elastic_cloud_user', ]); expect(apiResponse.body.username).to.be(validUsername); expect(apiResponse.body.authentication_provider).to.eql({ type: 'http', name: '__http__' }); @@ -192,6 +193,7 @@ export default function ({ getService }) { 'lookup_realm', 'authentication_provider', 'authentication_type', + 'elastic_cloud_user', ]); expect(apiResponse.body.username).to.be(validUsername); expect(apiResponse.body.authentication_provider).to.eql({ type: 'basic', name: 'basic' }); diff --git a/x-pack/test/security_api_integration/tests/http_bearer/header.ts b/x-pack/test/security_api_integration/tests/http_bearer/header.ts index 7de7d83e154e2..1cc080c3b3e77 100644 --- a/x-pack/test/security_api_integration/tests/http_bearer/header.ts +++ b/x-pack/test/security_api_integration/tests/http_bearer/header.ts @@ -27,6 +27,7 @@ export default function ({ getService }: FtrProviderContext) { ...authentication, authentication_provider: { name: '__http__', type: 'http' }, authentication_type: 'token', + elastic_cloud_user: false, }, }; } diff --git a/x-pack/test/security_api_integration/tests/kerberos/kerberos_login.ts b/x-pack/test/security_api_integration/tests/kerberos/kerberos_login.ts index 9eeb951c04e5c..091dfb1fb5ccc 100644 --- a/x-pack/test/security_api_integration/tests/kerberos/kerberos_login.ts +++ b/x-pack/test/security_api_integration/tests/kerberos/kerberos_login.ts @@ -157,6 +157,7 @@ export default function ({ getService }: FtrProviderContext) { lookup_realm: { name: 'kerb1', type: 'kerberos' }, authentication_provider: { type: 'kerberos', name: 'kerberos' }, authentication_type: 'token', + elastic_cloud_user: false, }); }); diff --git a/x-pack/test/security_api_integration/tests/login_selector/basic_functionality.ts b/x-pack/test/security_api_integration/tests/login_selector/basic_functionality.ts index 2666277780be7..df7d309261a38 100644 --- a/x-pack/test/security_api_integration/tests/login_selector/basic_functionality.ts +++ b/x-pack/test/security_api_integration/tests/login_selector/basic_functionality.ts @@ -64,6 +64,7 @@ export default function ({ getService }: FtrProviderContext) { 'lookup_realm', 'authentication_provider', 'authentication_type', + 'elastic_cloud_user', ]); expect(apiResponse.body.username).to.be(username); diff --git a/x-pack/test/security_api_integration/tests/oidc/authorization_code_flow/oidc_auth.ts b/x-pack/test/security_api_integration/tests/oidc/authorization_code_flow/oidc_auth.ts index 86d8fa2f77d2d..a5fad51792d30 100644 --- a/x-pack/test/security_api_integration/tests/oidc/authorization_code_flow/oidc_auth.ts +++ b/x-pack/test/security_api_integration/tests/oidc/authorization_code_flow/oidc_auth.ts @@ -225,6 +225,7 @@ export default function ({ getService }: FtrProviderContext) { 'lookup_realm', 'authentication_provider', 'authentication_type', + 'elastic_cloud_user', ]); expect(apiResponse.body.username).to.be('user1'); @@ -278,6 +279,7 @@ export default function ({ getService }: FtrProviderContext) { 'lookup_realm', 'authentication_provider', 'authentication_type', + 'elastic_cloud_user', ]); expect(apiResponse.body.username).to.be('user2'); diff --git a/x-pack/test/security_api_integration/tests/oidc/implicit_flow/oidc_auth.ts b/x-pack/test/security_api_integration/tests/oidc/implicit_flow/oidc_auth.ts index b71430193a14b..9a0483d5cebaa 100644 --- a/x-pack/test/security_api_integration/tests/oidc/implicit_flow/oidc_auth.ts +++ b/x-pack/test/security_api_integration/tests/oidc/implicit_flow/oidc_auth.ts @@ -152,6 +152,7 @@ export default function ({ getService }: FtrProviderContext) { 'lookup_realm', 'authentication_provider', 'authentication_type', + 'elastic_cloud_user', ]); expect(apiResponse.body.username).to.be('user1'); diff --git a/x-pack/test/security_api_integration/tests/pki/pki_auth.ts b/x-pack/test/security_api_integration/tests/pki/pki_auth.ts index 1158a8a4d7fa7..ae9f3d893534b 100644 --- a/x-pack/test/security_api_integration/tests/pki/pki_auth.ts +++ b/x-pack/test/security_api_integration/tests/pki/pki_auth.ts @@ -155,6 +155,7 @@ export default function ({ getService }: FtrProviderContext) { lookup_realm: { name: 'pki1', type: 'pki' }, authentication_provider: { name: 'pki', type: 'pki' }, authentication_type: 'token', + elastic_cloud_user: false, }); }); @@ -192,6 +193,7 @@ export default function ({ getService }: FtrProviderContext) { lookup_realm: { name: 'pki1', type: 'pki' }, authentication_provider: { name: 'pki', type: 'pki' }, authentication_type: 'realm', + elastic_cloud_user: false, }); checkCookieIsSet(parseCookie(response.headers['set-cookie'][0])!); diff --git a/x-pack/test/security_api_integration/tests/saml/saml_login.ts b/x-pack/test/security_api_integration/tests/saml/saml_login.ts index f7b3c732e72ea..998e906a47415 100644 --- a/x-pack/test/security_api_integration/tests/saml/saml_login.ts +++ b/x-pack/test/security_api_integration/tests/saml/saml_login.ts @@ -63,6 +63,7 @@ export default function ({ getService }: FtrProviderContext) { 'lookup_realm', 'authentication_provider', 'authentication_type', + 'elastic_cloud_user', ]); expect(apiResponse.body.username).to.be(username); From 92988cd9aa09d4074196e4ae0ab5757cc3f13223 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Fri, 3 Jun 2022 11:28:09 +0200 Subject: [PATCH 09/13] Review#1: change color of `None provided` to `$euiColorDisabled`, remove left\right padding from the Profile page header and content. --- .../account_management/user_profile/user_profile.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx index 8f16ec71f8aef..d132645431a61 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx @@ -18,7 +18,6 @@ import { EuiFormRow, EuiIcon, EuiIconTip, - EuiPageTemplate, EuiSpacer, EuiText, useEuiTheme, @@ -33,6 +32,7 @@ import type { CoreStart } from '@kbn/core/public'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { KibanaPageTemplate } from '@kbn/shared-ux-components'; import type { AuthenticatedUser, UserAvatar as IUserAvatar } from '../../../common'; import { @@ -161,10 +161,12 @@ export const UserProfile: FunctionComponent = ({ user, data }) /> ) : null} - = ({ user, data }) description: ( {item.description || ( - + = ({ user, data }) ) : null} - + From e5b1af63f886bd420406834e9444bb46dec8a64d Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Fri, 3 Jun 2022 12:33:21 +0200 Subject: [PATCH 10/13] Review#2: Get rid of KibanaPageTemplate. --- .../public/account_management/user_profile/user_profile.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx index d132645431a61..adf2364fd9b69 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx @@ -18,6 +18,7 @@ import { EuiFormRow, EuiIcon, EuiIconTip, + EuiPageTemplate, EuiSpacer, EuiText, useEuiTheme, @@ -32,7 +33,6 @@ import type { CoreStart } from '@kbn/core/public'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { useKibana } from '@kbn/kibana-react-plugin/public'; -import { KibanaPageTemplate } from '@kbn/shared-ux-components'; import type { AuthenticatedUser, UserAvatar as IUserAvatar } from '../../../common'; import { @@ -161,7 +161,7 @@ export const UserProfile: FunctionComponent = ({ user, data }) /> ) : null} - = ({ user, data }) ) : null} - +
From 6442c8cc5de7a1b94e169c7a5c3c9896376c9ed8 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Wed, 8 Jun 2022 13:46:19 +0200 Subject: [PATCH 11/13] Review#4: handle latest review comments. --- x-pack/plugins/security/common/index.ts | 2 +- .../common/model/authenticated_user.ts | 10 + x-pack/plugins/security/common/model/index.ts | 2 +- .../security/common/model/user_profile.ts | 10 +- .../account_management_app.test.tsx | 10 +- .../account_management_app.tsx | 18 +- .../account_management_page.test.tsx | 2 +- .../account_management_page.tsx | 4 +- .../public/account_management/index.ts | 3 +- .../account_management/user_profile/index.ts | 2 - .../user_profile/user_profile.test.tsx | 2 +- .../user_profile/user_profile.tsx | 687 +++++++++--------- .../public/components/api_clients_provider.ts | 23 - .../security/public/components/index.ts | 6 +- .../security_api_clients_provider.ts | 30 + .../public/components/use_current_user.ts | 4 +- .../user_avatar.tsx | 6 +- .../nav_control/nav_control_component.tsx | 7 +- .../nav_control/nav_control_service.test.ts | 12 +- .../nav_control/nav_control_service.tsx | 27 +- x-pack/plugins/security/public/plugin.tsx | 6 +- .../authentication/authentication_result.ts | 24 +- .../server/routes/user_profile/get.ts | 5 +- .../server/routes/user_profile/update.test.ts | 2 +- .../server/routes/user_profile/update.ts | 9 +- .../server/session_management/index.ts | 2 +- .../server/session_management/session.test.ts | 6 +- .../server/session_management/session.ts | 11 +- 28 files changed, 510 insertions(+), 422 deletions(-) delete mode 100644 x-pack/plugins/security/public/components/api_clients_provider.ts create mode 100644 x-pack/plugins/security/public/components/security_api_clients_provider.ts rename x-pack/plugins/security/public/{account_management/user_profile => components}/user_avatar.tsx (91%) diff --git a/x-pack/plugins/security/common/index.ts b/x-pack/plugins/security/common/index.ts index befc5293e39d5..579f51426a48a 100644 --- a/x-pack/plugins/security/common/index.ts +++ b/x-pack/plugins/security/common/index.ts @@ -20,7 +20,7 @@ export type { User, UserProfile, UserData, - UserAvatar, + UserAvatarData, UserInfo, ApiKey, UserRealm, diff --git a/x-pack/plugins/security/common/model/authenticated_user.ts b/x-pack/plugins/security/common/model/authenticated_user.ts index 2237384791e8b..7f7e965994e4b 100644 --- a/x-pack/plugins/security/common/model/authenticated_user.ts +++ b/x-pack/plugins/security/common/model/authenticated_user.ts @@ -12,8 +12,18 @@ import type { User } from './user'; const REALMS_ELIGIBLE_FOR_PASSWORD_CHANGE = ['reserved', 'native']; +/** + * An Elasticsearch realm that was used to resolve and authenticate the user. + */ export interface UserRealm { + /** + * Arbitrary name of the security realm. + */ name: string; + + /** + * Type of the security realm (file, native, saml etc.). + */ type: string; } diff --git a/x-pack/plugins/security/common/model/index.ts b/x-pack/plugins/security/common/model/index.ts index d655b23c0b439..817f3fcf84bc0 100644 --- a/x-pack/plugins/security/common/model/index.ts +++ b/x-pack/plugins/security/common/model/index.ts @@ -12,7 +12,7 @@ export type { UserProfile, UserData, UserInfo, - UserAvatar, + UserAvatarData, } from './user_profile'; export { getUserAvatarColor, diff --git a/x-pack/plugins/security/common/model/user_profile.ts b/x-pack/plugins/security/common/model/user_profile.ts index fc5489ff0a173..dd97c5c957962 100644 --- a/x-pack/plugins/security/common/model/user_profile.ts +++ b/x-pack/plugins/security/common/model/user_profile.ts @@ -21,7 +21,7 @@ export interface UserInfo extends User { /** * Avatar stored in user profile. */ -export interface UserAvatar { +export interface UserAvatarData { initials?: string; color?: string; imageUrl?: string; @@ -76,11 +76,11 @@ export const USER_AVATAR_MAX_INITIALS = 2; * Otherwise, a color is provided from EUI's Visualization Colors based on the display name. * * @param {UserInfo} user User info - * @param {UserAvatar} avatar User avatar + * @param {UserAvatarData} avatar User avatar */ export function getUserAvatarColor( user: Pick, - avatar?: UserAvatar + avatar?: UserAvatarData ) { if (avatar && avatar.color) { return avatar.color; @@ -97,11 +97,11 @@ export function getUserAvatarColor( * Otherwise, the initials are calculated based off the words in the display name, with a max length of 2 characters. * * @param {UserInfo} user User info - * @param {UserAvatar} avatar User avatar + * @param {UserAvatarData} avatar User avatar */ export function getUserAvatarInitials( user: Pick, - avatar?: UserAvatar + avatar?: UserAvatarData ) { if (avatar && avatar.initials) { return avatar.initials; diff --git a/x-pack/plugins/security/public/account_management/account_management_app.test.tsx b/x-pack/plugins/security/public/account_management/account_management_app.test.tsx index 00f245a77febf..517b83670bd67 100644 --- a/x-pack/plugins/security/public/account_management/account_management_app.test.tsx +++ b/x-pack/plugins/security/public/account_management/account_management_app.test.tsx @@ -31,7 +31,10 @@ describe('accountManagementApp', () => { application, getStartServices, authc, - apiClients: { userProfiles: new UserProfileAPIClient(http), users: new UserAPIClient(http) }, + securityApiClients: { + userProfiles: new UserProfileAPIClient(http), + users: new UserAPIClient(http), + }, }); expect(application.register).toHaveBeenCalledTimes(1); @@ -54,7 +57,10 @@ describe('accountManagementApp', () => { application, authc, getStartServices, - apiClients: { userProfiles: new UserProfileAPIClient(http), users: new UserAPIClient(http) }, + securityApiClients: { + userProfiles: new UserProfileAPIClient(http), + users: new UserAPIClient(http), + }, }); const [[{ mount }]] = application.register.mock.calls; diff --git a/x-pack/plugins/security/public/account_management/account_management_app.tsx b/x-pack/plugins/security/public/account_management/account_management_app.tsx index 104cc88591b4e..58e7885ca4d9d 100644 --- a/x-pack/plugins/security/public/account_management/account_management_app.tsx +++ b/x-pack/plugins/security/public/account_management/account_management_app.tsx @@ -25,21 +25,21 @@ import { I18nProvider } from '@kbn/i18n-react'; import { KibanaContextProvider, KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; import type { AuthenticationServiceSetup } from '../authentication'; -import type { ApiClients } from '../components'; -import { ApiClientsProvider, AuthenticationProvider } from '../components'; +import type { SecurityApiClients } from '../components'; +import { AuthenticationProvider, SecurityApiClientsProvider } from '../components'; import type { BreadcrumbsChangeHandler } from '../components/breadcrumb'; import { BreadcrumbsProvider } from '../components/breadcrumb'; interface CreateDeps { application: ApplicationSetup; authc: AuthenticationServiceSetup; - apiClients: ApiClients; + securityApiClients: SecurityApiClients; getStartServices: StartServicesAccessor; } export const accountManagementApp = Object.freeze({ id: 'security_account', - create({ application, authc, getStartServices, apiClients }: CreateDeps) { + create({ application, authc, getStartServices, securityApiClients }: CreateDeps) { application.register({ id: this.id, title: i18n.translate('xpack.security.account.breadcrumb', { @@ -59,7 +59,7 @@ export const accountManagementApp = Object.freeze({ theme$={theme$} history={history} authc={authc} - apiClients={apiClients} + securityApiClients={securityApiClients} > , @@ -77,7 +77,7 @@ export interface ProvidersProps { theme$: Observable; history: History; authc: AuthenticationServiceSetup; - apiClients: ApiClients; + securityApiClients: SecurityApiClients; onChange?: BreadcrumbsChangeHandler; } @@ -86,13 +86,13 @@ export const Providers: FunctionComponent = ({ theme$, history, authc, - apiClients, + securityApiClients, onChange, children, }) => ( - + @@ -100,7 +100,7 @@ export const Providers: FunctionComponent = ({ - + ); diff --git a/x-pack/plugins/security/public/account_management/account_management_page.test.tsx b/x-pack/plugins/security/public/account_management/account_management_page.test.tsx index a75d7192089b4..c58b3c7d30e74 100644 --- a/x-pack/plugins/security/public/account_management/account_management_page.test.tsx +++ b/x-pack/plugins/security/public/account_management/account_management_page.test.tsx @@ -58,7 +58,7 @@ describe('', () => { theme$={theme$} history={history} authc={authc} - apiClients={{ + securityApiClients={{ userProfiles: new UserProfileAPIClient(coreStart.http), users: new UserAPIClient(coreStart.http), }} diff --git a/x-pack/plugins/security/public/account_management/account_management_page.tsx b/x-pack/plugins/security/public/account_management/account_management_page.tsx index 216ec8a4190b8..6b5f9c033422c 100644 --- a/x-pack/plugins/security/public/account_management/account_management_page.tsx +++ b/x-pack/plugins/security/public/account_management/account_management_page.tsx @@ -13,7 +13,7 @@ import type { CoreStart } from '@kbn/core/public'; import { i18n } from '@kbn/i18n'; import { useKibana } from '@kbn/kibana-react-plugin/public'; -import type { UserAvatar } from '../../common'; +import type { UserAvatarData } from '../../common'; import { canUserHaveProfile } from '../../common/model'; import { useCurrentUser, useUserProfile } from '../components'; import { Breadcrumb } from '../components/breadcrumb'; @@ -23,7 +23,7 @@ export const AccountManagementPage: FunctionComponent = () => { const { services } = useKibana(); const currentUser = useCurrentUser(); - const userProfile = useUserProfile<{ avatar: UserAvatar }>('avatar'); + const userProfile = useUserProfile<{ avatar: UserAvatarData }>('avatar'); // If we fail to load profile, we treat it as a failure _only_ if user is supposed // to have a profile. For example, anonymous and users authenticated via diff --git a/x-pack/plugins/security/public/account_management/index.ts b/x-pack/plugins/security/public/account_management/index.ts index a05537136602a..a78746541e5a8 100644 --- a/x-pack/plugins/security/public/account_management/index.ts +++ b/x-pack/plugins/security/public/account_management/index.ts @@ -6,5 +6,4 @@ */ export { accountManagementApp } from './account_management_app'; -export type { UserAvatarProps } from './user_profile'; -export { UserProfileAPIClient, UserAvatar } from './user_profile'; +export { UserProfileAPIClient } from './user_profile'; diff --git a/x-pack/plugins/security/public/account_management/user_profile/index.ts b/x-pack/plugins/security/public/account_management/user_profile/index.ts index f8a8f953aa833..17eda36459ad5 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/index.ts +++ b/x-pack/plugins/security/public/account_management/user_profile/index.ts @@ -6,8 +6,6 @@ */ export { UserProfile } from './user_profile'; -export { UserAvatar } from './user_avatar'; export type { UserProfileProps, UserProfileFormValues } from './user_profile'; -export type { UserAvatarProps } from './user_avatar'; export { UserProfileAPIClient } from './user_profile_api_client'; diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_profile.test.tsx b/x-pack/plugins/security/public/account_management/user_profile/user_profile.test.tsx index 2861430d90353..c0c90c6421ab2 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/user_profile.test.tsx +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile.test.tsx @@ -31,7 +31,7 @@ const wrapper: FunctionComponent = ({ children }) => ( theme$={theme$} history={history} authc={authc} - apiClients={{ + securityApiClients={{ userProfiles: new UserProfileAPIClient(coreStart.http), users: new UserAPIClient(coreStart.http), }} diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx index adf2364fd9b69..0509781243a1e 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx @@ -34,14 +34,14 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { useKibana } from '@kbn/kibana-react-plugin/public'; -import type { AuthenticatedUser, UserAvatar as IUserAvatar } from '../../../common'; +import type { AuthenticatedUser, UserAvatarData } from '../../../common'; import { canUserChangeDetails, canUserChangePassword, getUserAvatarColor, getUserAvatarInitials, } from '../../../common/model'; -import { useApiClients } from '../../components'; +import { UserAvatar, useSecurityApiClients } from '../../components'; import { Breadcrumb } from '../../components/breadcrumb'; import { FormChangesProvider, @@ -53,13 +53,12 @@ import { FormLabel } from '../../components/form_label'; import { FormRow, OptionalText } from '../../components/form_row'; import { ChangePasswordModal } from '../../management/users/edit_user/change_password_modal'; import { isUserReserved } from '../../management/users/user_utils'; -import { UserAvatar } from './user_avatar'; import { createImageHandler, getRandomColor, IMAGE_FILE_TYPES, VALID_HEX_COLOR } from './utils'; export interface UserProfileProps { user: AuthenticatedUser; data?: { - avatar?: IUserAvatar; + avatar?: UserAvatarData; }; } @@ -78,6 +77,358 @@ export interface UserProfileFormValues { avatarType: 'initials' | 'image'; } +function UserDetailsEditor({ user }: { user: AuthenticatedUser }) { + const { services } = useKibana(); + + const canChangeDetails = canUserChangeDetails(user, services.application.capabilities); + if (!canChangeDetails) { + return null; + } + + return ( + + + + } + description={ + + } + > + + + + } + labelAppend={} + fullWidth + > + + + + + + + } + labelAppend={} + fullWidth + > + + + + ); +} + +function UserAvatarEditor({ + user, + formik, +}: { + user: AuthenticatedUser; + formik: ReturnType; +}) { + const { euiTheme } = useEuiTheme(); + if (!formik.values.data) { + return null; + } + + const isReservedUser = isUserReserved(user); + return ( + + + + } + description={ + + } + > + + + {formik.values.avatarType === 'image' && !formik.values.data.avatar.imageUrl ? ( + + ) : ( + + )} + + + + + + } + fullWidth + > + + ), + }, + { + id: 'image', + label: ( + + ), + iconType: 'image', + }, + ]} + onChange={(id: string) => formik.setFieldValue('avatarType', id)} + isFullWidth + /> + + + + + + {formik.values.avatarType === 'image' ? ( + + + + } + fullWidth + > + + ) : ( + + ) + } + onChange={createImageHandler((imageUrl) => { + formik.setFieldValue('data.avatar.imageUrl', imageUrl ?? ''); + })} + validate={{ + required: i18n.translate( + 'xpack.security.accountManagement.userProfile.imageUrlRequiredError', + { defaultMessage: 'Upload an image.' } + ), + }} + accept={IMAGE_FILE_TYPES.join(',')} + display="default" + fullWidth + /> + + ) : ( + + + + + + } + fullWidth + > + + + + + + + + } + labelAppend={ + !isReservedUser ? ( + formik.setFieldValue('data.avatar.color', getRandomColor())} + size="xs" + flush="right" + style={{ height: euiTheme.base }} + > + + + ) : null + } + fullWidth + > + { + formik.setFieldValue('data.avatar.color', value); + }} + fullWidth + /> + + + + )} + + ); +} + +function UserPasswordEditor({ + user, + onShowPasswordForm, +}: { + user: AuthenticatedUser; + onShowPasswordForm: () => void; +}) { + const canChangePassword = canUserChangePassword(user); + if (!canChangePassword) { + return null; + } + + return ( + + + + } + description={ + + } + > + + } + fullWidth + > + + + + + + ); +} + export const UserProfile: FunctionComponent = ({ user, data }) => { const { euiTheme } = useEuiTheme(); const { services } = useKibana(); @@ -86,8 +437,6 @@ export const UserProfile: FunctionComponent = ({ user, data }) const titleId = useGeneratedHtmlId(); const [showChangePasswordForm, setShowChangePasswordForm] = useState(false); - const isReservedUser = isUserReserved(user); - const canChangePassword = canUserChangePassword(user); const canChangeDetails = canUserChangeDetails(user, services.application.capabilities); const rightSideItems = [ @@ -213,324 +562,12 @@ export const UserProfile: FunctionComponent = ({ user, data }) restrictWidth={1000} >
- {canChangeDetails ? ( - - - - } - description={ - - } - > - - - - } - labelAppend={} - fullWidth - > - - - - - - - } - labelAppend={} - fullWidth - > - - - - ) : null} - - {formik.values.data ? ( - - - - } - description={ - - } - > - - - {formik.values.avatarType === 'image' && - !formik.values.data.avatar.imageUrl ? ( - - ) : ( - - )} - - - - - - } - fullWidth - > - - ), - }, - { - id: 'image', - label: ( - - ), - iconType: 'image', - }, - ]} - onChange={(id: string) => formik.setFieldValue('avatarType', id)} - isFullWidth - /> - - - - - - {formik.values.avatarType === 'image' ? ( - - - - } - fullWidth - > - - ) : ( - - ) - } - onChange={createImageHandler((imageUrl) => { - formik.setFieldValue('data.avatar.imageUrl', imageUrl ?? ''); - })} - validate={{ - required: i18n.translate( - 'xpack.security.accountManagement.userProfile.imageUrlRequiredError', - { defaultMessage: 'Upload an image.' } - ), - }} - accept={IMAGE_FILE_TYPES.join(',')} - display="default" - fullWidth - /> - - ) : ( - - - - - - } - fullWidth - > - - - - - - - - } - labelAppend={ - !isReservedUser ? ( - - formik.setFieldValue('data.avatar.color', getRandomColor()) - } - size="xs" - flush="right" - style={{ height: euiTheme.base }} - > - - - ) : null - } - fullWidth - > - { - formik.setFieldValue('data.avatar.color', value); - }} - fullWidth - /> - - - - )} - - ) : null} - - {canChangePassword ? ( - - - - } - description={ - - } - > - - } - fullWidth - > - setShowChangePasswordForm(true)} - iconType="lock" - data-test-subj="openChangePasswordForm" - > - - - - - ) : null} + + + setShowChangePasswordForm(true)} + /> @@ -542,7 +579,7 @@ export const UserProfile: FunctionComponent = ({ user, data }) export function useUserProfileForm({ user, data }: UserProfileProps) { const { services } = useKibana(); - const { userProfiles, users } = useApiClients(); + const { userProfiles, users } = useSecurityApiClients(); const [initialValues, resetInitialValues] = useState({ user: { diff --git a/x-pack/plugins/security/public/components/api_clients_provider.ts b/x-pack/plugins/security/public/components/api_clients_provider.ts deleted file mode 100644 index 158a0855f172f..0000000000000 --- a/x-pack/plugins/security/public/components/api_clients_provider.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * 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 constate from 'constate'; - -import type { UserProfileAPIClient } from '../account_management'; -import type { UserAPIClient } from '../management'; - -export interface ApiClients { - userProfiles: UserProfileAPIClient; - users: UserAPIClient; -} - -const [ApiClientsProvider, useApiClients] = constate(({ userProfiles, users }: ApiClients) => ({ - userProfiles, - users, -})); - -export { ApiClientsProvider, useApiClients }; diff --git a/x-pack/plugins/security/public/components/index.ts b/x-pack/plugins/security/public/components/index.ts index 9f3eaacb61f49..4787bcacd8662 100644 --- a/x-pack/plugins/security/public/components/index.ts +++ b/x-pack/plugins/security/public/components/index.ts @@ -5,11 +5,13 @@ * 2.0. */ -export { ApiClientsProvider, useApiClients } from './api_clients_provider'; -export type { ApiClients } from './api_clients_provider'; +export { SecurityApiClientsProvider, useSecurityApiClients } from './security_api_clients_provider'; +export type { SecurityApiClients } from './security_api_clients_provider'; export { AuthenticationProvider, useAuthentication, useUserProfile, useCurrentUser, } from './use_current_user'; +export { UserAvatar } from './user_avatar'; +export type { UserAvatarProps } from './user_avatar'; diff --git a/x-pack/plugins/security/public/components/security_api_clients_provider.ts b/x-pack/plugins/security/public/components/security_api_clients_provider.ts new file mode 100644 index 0000000000000..0434ea6b85390 --- /dev/null +++ b/x-pack/plugins/security/public/components/security_api_clients_provider.ts @@ -0,0 +1,30 @@ +/* + * 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 constate from 'constate'; + +import type { UserProfileAPIClient } from '../account_management'; +import type { UserAPIClient } from '../management'; + +/** + * Represents a collection of the high-level abstractions (clients) to interact with the Security specific APIs. + */ +export interface SecurityApiClients { + userProfiles: UserProfileAPIClient; + users: UserAPIClient; +} + +/** + * The `SecurityApiClientsProvider` React context provider is used to provide UI components with the Security API + * clients that can be subsequently consumed through `useSecurityApiClients` hook. + */ +export const [SecurityApiClientsProvider, useSecurityApiClients] = constate( + ({ userProfiles, users }: SecurityApiClients) => ({ + userProfiles, + users, + }) +); diff --git a/x-pack/plugins/security/public/components/use_current_user.ts b/x-pack/plugins/security/public/components/use_current_user.ts index 601cb42213724..12df907679384 100644 --- a/x-pack/plugins/security/public/components/use_current_user.ts +++ b/x-pack/plugins/security/public/components/use_current_user.ts @@ -9,7 +9,7 @@ import constate from 'constate'; import useAsync from 'react-use/lib/useAsync'; import useObservable from 'react-use/lib/useObservable'; -import { useApiClients } from '.'; +import { useSecurityApiClients } from '.'; import type { UserData } from '../../common'; import type { AuthenticationServiceSetup } from '../authentication'; @@ -29,7 +29,7 @@ export function useCurrentUser() { } export function useUserProfile(dataPath?: string) { - const { userProfiles } = useApiClients(); + const { userProfiles } = useSecurityApiClients(); const dataUpdateState = useObservable(userProfiles.dataUpdates$); return useAsync(() => userProfiles.get(dataPath), [userProfiles, dataUpdateState]); } diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_avatar.tsx b/x-pack/plugins/security/public/components/user_avatar.tsx similarity index 91% rename from x-pack/plugins/security/public/account_management/user_profile/user_avatar.tsx rename to x-pack/plugins/security/public/components/user_avatar.tsx index d6c2621ca45a2..ff9bb1055bcc7 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/user_avatar.tsx +++ b/x-pack/plugins/security/public/components/user_avatar.tsx @@ -10,17 +10,17 @@ import { EuiAvatar, useEuiTheme } from '@elastic/eui'; import type { FunctionComponent, HTMLAttributes } from 'react'; import React from 'react'; -import type { UserAvatar as IUserAvatar, UserInfo } from '../../../common'; +import type { UserAvatarData, UserInfo } from '../../common'; import { getUserAvatarColor, getUserAvatarInitials, getUserDisplayName, USER_AVATAR_MAX_INITIALS, -} from '../../../common/model'; +} from '../../common/model'; export interface UserAvatarProps extends Omit, 'color'> { user?: Pick; - avatar?: IUserAvatar; + avatar?: UserAvatarData; size?: EuiAvatarProps['size']; isDisabled?: EuiAvatarProps['isDisabled']; } diff --git a/x-pack/plugins/security/public/nav_control/nav_control_component.tsx b/x-pack/plugins/security/public/nav_control/nav_control_component.tsx index 8a37bb5c6153a..1a32a083793f3 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_component.tsx +++ b/x-pack/plugins/security/public/nav_control/nav_control_component.tsx @@ -21,10 +21,9 @@ import type { Observable } from 'rxjs'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import type { UserAvatar as IUserAvatar } from '../../common'; +import type { UserAvatarData } from '../../common'; import { getUserDisplayName, isUserAnonymous } from '../../common/model'; -import { UserAvatar } from '../account_management'; -import { useCurrentUser, useUserProfile } from '../components'; +import { useCurrentUser, UserAvatar, useUserProfile } from '../components'; export interface UserMenuLink { label: string; @@ -48,7 +47,7 @@ export const SecurityNavControl: FunctionComponent = ({ const userMenuLinks = useObservable(userMenuLinks$, []); const [isOpen, setIsOpen] = useState(false); - const userProfile = useUserProfile<{ avatar: IUserAvatar }>('avatar'); + const userProfile = useUserProfile<{ avatar: UserAvatarData }>('avatar'); const currentUser = useCurrentUser(); // User profiles do not exist for anonymous users so need to fetch current user as well const displayName = currentUser.value ? getUserDisplayName(currentUser.value) : ''; diff --git a/x-pack/plugins/security/public/nav_control/nav_control_service.test.ts b/x-pack/plugins/security/public/nav_control/nav_control_service.test.ts index 93d2927f678a8..9afb9917ba63e 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_service.test.ts +++ b/x-pack/plugins/security/public/nav_control/nav_control_service.test.ts @@ -59,7 +59,7 @@ describe('SecurityNavControlService', () => { navControlService.setup({ securityLicense: new SecurityLicenseService().setup({ license$ }).license, logoutUrl: '/some/logout/url', - apiClients: mockApiClients(coreStart.http), + securityApiClients: mockApiClients(coreStart.http), }); coreStart.chrome.navControls.registerRight = jest.fn(); @@ -126,7 +126,7 @@ describe('SecurityNavControlService', () => { navControlService.setup({ securityLicense: new SecurityLicenseService().setup({ license$ }).license, logoutUrl: '/some/logout/url', - apiClients: mockApiClients(coreStart.http), + securityApiClients: mockApiClients(coreStart.http), }); navControlService.start({ core: coreStart, authc }); @@ -146,7 +146,7 @@ describe('SecurityNavControlService', () => { navControlService.setup({ securityLicense: new SecurityLicenseService().setup({ license$ }).license, logoutUrl: '/some/logout/url', - apiClients: mockApiClients(coreStart.http), + securityApiClients: mockApiClients(coreStart.http), }); coreStart.http.anonymousPaths.isAnonymous.mockReturnValue(true); @@ -163,7 +163,7 @@ describe('SecurityNavControlService', () => { navControlService.setup({ securityLicense: new SecurityLicenseService().setup({ license$ }).license, logoutUrl: '/some/logout/url', - apiClients: mockApiClients(coreStart.http), + securityApiClients: mockApiClients(coreStart.http), }); navControlService.start({ core: coreStart, authc }); @@ -185,7 +185,7 @@ describe('SecurityNavControlService', () => { navControlService.setup({ securityLicense: new SecurityLicenseService().setup({ license$ }).license, logoutUrl: '/some/logout/url', - apiClients: mockApiClients(coreStart.http), + securityApiClients: mockApiClients(coreStart.http), }); navControlService.start({ core: coreStart, authc }); @@ -208,7 +208,7 @@ describe('SecurityNavControlService', () => { navControlService.setup({ securityLicense: new SecurityLicenseService().setup({ license$ }).license, logoutUrl: '/some/logout/url', - apiClients: mockApiClients(coreSetup.http), + securityApiClients: mockApiClients(coreSetup.http), }); }); diff --git a/x-pack/plugins/security/public/nav_control/nav_control_service.tsx b/x-pack/plugins/security/public/nav_control/nav_control_service.tsx index 47cf1027851f9..592f5d16f523e 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_service.tsx +++ b/x-pack/plugins/security/public/nav_control/nav_control_service.tsx @@ -19,15 +19,15 @@ import { KibanaContextProvider, KibanaThemeProvider } from '@kbn/kibana-react-pl import type { SecurityLicense } from '../../common/licensing'; import type { AuthenticationServiceSetup } from '../authentication'; -import type { ApiClients } from '../components'; -import { ApiClientsProvider, AuthenticationProvider } from '../components'; +import type { SecurityApiClients } from '../components'; +import { AuthenticationProvider, SecurityApiClientsProvider } from '../components'; import type { UserMenuLink } from './nav_control_component'; import { SecurityNavControl } from './nav_control_component'; interface SetupDeps { securityLicense: SecurityLicense; logoutUrl: string; - apiClients: ApiClients; + securityApiClients: SecurityApiClients; } interface StartDeps { @@ -50,7 +50,7 @@ export interface SecurityNavControlServiceStart { export class SecurityNavControlService { private securityLicense!: SecurityLicense; private logoutUrl!: string; - private apiClients!: ApiClients; + private securityApiClients!: SecurityApiClients; private navControlRegistered!: boolean; @@ -59,10 +59,10 @@ export class SecurityNavControlService { private readonly stop$ = new ReplaySubject(1); private userMenuLinks$ = new BehaviorSubject([]); - public setup({ securityLicense, logoutUrl, apiClients }: SetupDeps) { + public setup({ securityLicense, logoutUrl, securityApiClients }: SetupDeps) { this.securityLicense = securityLicense; this.logoutUrl = logoutUrl; - this.apiClients = apiClients; + this.securityApiClients = securityApiClients; } public start({ core, authc }: StartDeps): SecurityNavControlServiceStart { @@ -121,7 +121,12 @@ export class SecurityNavControlService { order: 2000, mount: (element: HTMLElement) => { ReactDOM.render( - + ; } @@ -154,16 +159,16 @@ export const Providers: FunctionComponent = ({ authc, services, theme$, - apiClients, + securityApiClients, children, }) => ( - + {children} - + ); diff --git a/x-pack/plugins/security/public/plugin.tsx b/x-pack/plugins/security/public/plugin.tsx index 98505189d459c..f648c4963884e 100644 --- a/x-pack/plugins/security/public/plugin.tsx +++ b/x-pack/plugins/security/public/plugin.tsx @@ -91,7 +91,7 @@ export class SecurityPlugin http: core.http, }); - const apiClients = { + const securityApiClients = { userProfiles: new UserProfileAPIClient(core.http), users: new UserAPIClient(core.http), }; @@ -99,14 +99,14 @@ export class SecurityPlugin this.navControlService.setup({ securityLicense: license, logoutUrl: getLogoutUrl(core.http), - apiClients, + securityApiClients, }); accountManagementApp.create({ authc: this.authc, application: core.application, getStartServices: core.getStartServices, - apiClients, + securityApiClients, }); if (management) { diff --git a/x-pack/plugins/security/server/authentication/authentication_result.ts b/x-pack/plugins/security/server/authentication/authentication_result.ts index afadf04717bb2..62b93a0f0c337 100644 --- a/x-pack/plugins/security/server/authentication/authentication_result.ts +++ b/x-pack/plugins/security/server/authentication/authentication_result.ts @@ -53,17 +53,23 @@ interface AuthenticationOptions { userProfileGrant?: UserProfileGrant; } -export type SucceededAuthenticationResultOptions = Pick< - AuthenticationOptions, - 'authHeaders' | 'userProfileGrant' | 'authResponseHeaders' | 'state' ->; +export interface SucceededAuthenticationResultOptions { + state?: unknown; + authHeaders?: AuthHeaders; + authResponseHeaders?: AuthHeaders; + userProfileGrant?: UserProfileGrant; +} -export type RedirectedAuthenticationResultOptions = Pick< - AuthenticationOptions, - 'user' | 'userProfileGrant' | 'authResponseHeaders' | 'state' ->; +export interface RedirectedAuthenticationResultOptions { + state?: unknown; + user?: AuthenticatedUser; + authResponseHeaders?: AuthHeaders; + userProfileGrant?: UserProfileGrant; +} -export type FailedAuthenticationResultOptions = Pick; +export interface FailedAuthenticationResultOptions { + authResponseHeaders?: AuthHeaders; +} /** * Represents the result of an authentication attempt. diff --git a/x-pack/plugins/security/server/routes/user_profile/get.ts b/x-pack/plugins/security/server/routes/user_profile/get.ts index d1212dc064f7a..8e62da27e050e 100644 --- a/x-pack/plugins/security/server/routes/user_profile/get.ts +++ b/x-pack/plugins/security/server/routes/user_profile/get.ts @@ -10,6 +10,7 @@ import { schema } from '@kbn/config-schema'; import type { RouteDefinitionParams } from '..'; import type { AuthenticatedUserProfile } from '../../../common'; import { wrapIntoCustomErrorResponse } from '../../errors'; +import { getPrintableSessionId } from '../../session_management'; import { createLicensedRouteHandler } from '../licensed_route_handler'; export function defineGetUserProfileRoute({ @@ -32,7 +33,9 @@ export function defineGetUserProfileRoute({ } if (!session.userProfileId) { - logger.warn(`User profile missing from current session. (sid: ${session.sid.slice(-10)})`); + logger.warn( + `User profile missing from current session. (sid: ${getPrintableSessionId(session.sid)})` + ); return response.notFound(); } diff --git a/x-pack/plugins/security/server/routes/user_profile/update.test.ts b/x-pack/plugins/security/server/routes/user_profile/update.test.ts index a842194a7eba3..785b644a3fa12 100644 --- a/x-pack/plugins/security/server/routes/user_profile/update.test.ts +++ b/x-pack/plugins/security/server/routes/user_profile/update.test.ts @@ -10,7 +10,7 @@ import type { RequestHandler, RouteConfig } from '@kbn/core/server'; import { kibanaResponseFactory } from '@kbn/core/server'; import { httpServerMock } from '@kbn/core/server/mocks'; import type { PublicMethodsOf } from '@kbn/utility-types'; -import type { DeeplyMockedKeys } from '@kbn/utility-types/jest'; +import type { DeeplyMockedKeys } from '@kbn/utility-types-jest'; import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; import type { InternalAuthenticationServiceStart } from '../../authentication'; diff --git a/x-pack/plugins/security/server/routes/user_profile/update.ts b/x-pack/plugins/security/server/routes/user_profile/update.ts index d6f4726e4b7d5..d4c030d0e0306 100644 --- a/x-pack/plugins/security/server/routes/user_profile/update.ts +++ b/x-pack/plugins/security/server/routes/user_profile/update.ts @@ -9,6 +9,7 @@ import { schema } from '@kbn/config-schema'; import type { RouteDefinitionParams } from '..'; import { wrapIntoCustomErrorResponse } from '../../errors'; +import { getPrintableSessionId } from '../../session_management'; import { createLicensedRouteHandler } from '../licensed_route_handler'; export function defineUpdateUserProfileDataRoute({ @@ -33,15 +34,17 @@ export function defineUpdateUserProfileDataRoute({ } if (!session.userProfileId) { - logger.warn(`User profile missing from current session. (sid: ${session.sid.slice(-10)})`); + logger.warn( + `User profile missing from current session. (sid: ${getPrintableSessionId(session.sid)})` + ); return response.notFound(); } const currentUser = getAuthenticationService().getCurrentUser(request); if (currentUser?.elastic_cloud_user) { logger.warn( - `Elastic Cloud SSO users aren't allowed to update profiles in Kibana. (sid: ${session.sid.slice( - -10 + `Elastic Cloud SSO users aren't allowed to update profiles in Kibana. (sid: ${getPrintableSessionId( + session.sid )})` ); return response.forbidden(); diff --git a/x-pack/plugins/security/server/session_management/index.ts b/x-pack/plugins/security/server/session_management/index.ts index 09787ed419854..5145efe587661 100644 --- a/x-pack/plugins/security/server/session_management/index.ts +++ b/x-pack/plugins/security/server/session_management/index.ts @@ -6,6 +6,6 @@ */ export type { SessionValue } from './session'; -export { Session } from './session'; +export { Session, getPrintableSessionId } from './session'; export type { SessionManagementServiceStart } from './session_management_service'; export { SessionManagementService } from './session_management_service'; diff --git a/x-pack/plugins/security/server/session_management/session.test.ts b/x-pack/plugins/security/server/session_management/session.test.ts index fba1137413bee..dfe9ca8d5747d 100644 --- a/x-pack/plugins/security/server/session_management/session.test.ts +++ b/x-pack/plugins/security/server/session_management/session.test.ts @@ -15,7 +15,7 @@ import { mockAuthenticatedUser } from '../../common/model/authenticated_user.moc import { ConfigSchema, createConfig } from '../config'; import { sessionCookieMock, sessionIndexMock, sessionMock } from './index.mock'; import type { SessionValueContentToEncrypt } from './session'; -import { Session } from './session'; +import { getPrintableSessionId, Session } from './session'; import type { SessionCookie } from './session_cookie'; import type { SessionIndex } from './session_index'; @@ -1038,4 +1038,8 @@ describe('Session', () => { }); }); }); + + it('#getPrintableSessionId', async () => { + expect(getPrintableSessionId('1234567890abcdefghijklmno')).toBe('fghijklmno'); + }); }); diff --git a/x-pack/plugins/security/server/session_management/session.ts b/x-pack/plugins/security/server/session_management/session.ts index a0458466ada53..8f6181ae3bee3 100644 --- a/x-pack/plugins/security/server/session_management/session.ts +++ b/x-pack/plugins/security/server/session_management/session.ts @@ -107,6 +107,15 @@ export type InvalidateSessionsFilter = const SID_BYTE_LENGTH = 32; const AAD_BYTE_LENGTH = 32; +/** + * Returns last 10 characters of the session identifier. Referring to the specific session by its identifier is useful + * for logging and debugging purposes, but we cannot include full session ID in logs because of the security reasons. + * @param sid Full user session id + */ +export function getPrintableSessionId(sid: string) { + return sid.slice(-10); +} + export class Session { /** * Used to encrypt and decrypt portion of the session value using configured encryption key. @@ -479,7 +488,7 @@ export class Session { * @param [sid] Session ID to create logger for. */ private getLoggerForSID(sid?: string) { - return this.options.logger.get(sid?.slice(-10) ?? 'no_session'); + return this.options.logger.get(sid ? getPrintableSessionId(sid) : 'no_session'); } /** From 1721b2701b621fde4d4dad618a7dd6e749c5d143 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Wed, 8 Jun 2022 16:09:31 +0200 Subject: [PATCH 12/13] Increase limits. --- packages/kbn-optimizer/limits.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 88d7b25abdcd0..29a396e371b9d 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -52,7 +52,7 @@ pageLoadAssetSize: savedObjectsTagging: 59482 savedObjectsTaggingOss: 20590 searchprofiler: 67080 - security: 100000 + security: 115240 snapshotRestore: 79032 spaces: 57868 telemetry: 51957 From dda0a109681c44e07453d20be71cff1d5c773a3c Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Thu, 9 Jun 2022 06:50:28 +0200 Subject: [PATCH 13/13] Review#5: use `disabledText` color for `None provided` label. --- .../public/account_management/user_profile/user_profile.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx index 0509781243a1e..f0371cc8d868b 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx @@ -542,7 +542,7 @@ export const UserProfile: FunctionComponent = ({ user, data }) description: ( {item.description || ( - +