diff --git a/package.json b/package.json index fbf76e833a675..67b1bea7b75de 100644 --- a/package.json +++ b/package.json @@ -288,6 +288,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 6e603698d053a..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: 95864 + security: 115240 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/cloud/public/plugin.test.ts b/x-pack/plugins/cloud/public/plugin.test.ts index 666af04b4bee6..9aed6f7f27cbb 100644 --- a/x-pack/plugins/cloud/public/plugin.test.ts +++ b/x-pack/plugins/cloud/public/plugin.test.ts @@ -170,13 +170,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 219303b2ea7bc..f3b07069ab578 100644 --- a/x-pack/plugins/cloud/public/plugin.tsx +++ b/x-pack/plugins/cloud/public/plugin.tsx @@ -262,11 +262,7 @@ 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 (user.elastic_cloud_user) { // If the user is managed by ESS, use the plain username as the user ID: // The username is expected to be unique for these users, // and it matches how users are identified in the Cloud UI, so it allows us to correlate them. 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/index.ts b/x-pack/plugins/security/common/index.ts index 0da855b153be8..579f51426a48a 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, + UserAvatarData, + UserInfo, ApiKey, UserRealm, } from './model'; 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.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..7f7e965994e4b 100644 --- a/x-pack/plugins/security/common/model/authenticated_user.ts +++ b/x-pack/plugins/security/common/model/authenticated_user.ts @@ -5,13 +5,25 @@ * 2.0. */ +import type { Capabilities } from '@kbn/core/types'; + import type { AuthenticationProvider } from './authentication_provider'; 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; } @@ -40,11 +52,38 @@ 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) { + return user.authentication_provider.type === 'anonymous'; } -export function canUserChangePassword(user: AuthenticatedUser) { +/** + * 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..817f3fcf84bc0 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, + UserAvatarData, +} 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..dd97c5c957962 --- /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 UserAvatarData { + 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 {UserAvatarData} avatar User avatar + */ +export function getUserAvatarColor( + user: Pick, + avatar?: UserAvatarData +) { + 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 {UserAvatarData} avatar User avatar + */ +export function getUserAvatarInitials( + user: Pick, + avatar?: UserAvatarData +) { + 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..517b83670bd67 --- /dev/null +++ b/x-pack/plugins/security/public/account_management/account_management_app.test.tsx @@ -0,0 +1,84 @@ +/* + * 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, + securityApiClients: { + 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, + securityApiClients: { + 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..58e7885ca4d9d --- /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 { 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; + securityApiClients: SecurityApiClients; + getStartServices: StartServicesAccessor; +} + +export const accountManagementApp = Object.freeze({ + id: 'security_account', + create({ application, authc, getStartServices, securityApiClients }: 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; + securityApiClients: SecurityApiClients; + onChange?: BreadcrumbsChangeHandler; +} + +export const Providers: FunctionComponent = ({ + services, + theme$, + history, + authc, + securityApiClients, + 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..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 @@ -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..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 @@ -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 { UserAvatarData } 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: 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 + // 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..a78746541e5a8 100644 --- a/x-pack/plugins/security/public/account_management/index.ts +++ b/x-pack/plugins/security/public/account_management/index.ts @@ -6,6 +6,4 @@ */ export { accountManagementApp } from './account_management_app'; - -export type { ChangePasswordProps } from './change_password'; -export type { PersonalInfoProps } from './personal_info'; +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 new file mode 100644 index 0000000000000..17eda36459ad5 --- /dev/null +++ b/x-pack/plugins/security/public/account_management/user_profile/index.ts @@ -0,0 +1,11 @@ +/* + * 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 type { UserProfileProps, UserProfileFormValues } from './user_profile'; +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 new file mode 100644 index 0000000000000..c0c90c6421ab2 --- /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..f0371cc8d868b --- /dev/null +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx @@ -0,0 +1,738 @@ +/* + * 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, UserAvatarData } from '../../../common'; +import { + canUserChangeDetails, + canUserChangePassword, + getUserAvatarColor, + getUserAvatarInitials, +} from '../../../common/model'; +import { UserAvatar, useSecurityApiClients } 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 { createImageHandler, getRandomColor, IMAGE_FILE_TYPES, VALID_HEX_COLOR } from './utils'; + +export interface UserProfileProps { + user: AuthenticatedUser; + data?: { + avatar?: UserAvatarData; + }; +} + +export interface UserProfileFormValues { + user: { + full_name: string; + email: string; + }; + data?: { + avatar: { + initials: string; + color: string; + imageUrl: string; + }; + }; + 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(); + const formik = useUserProfileForm({ user, data }); + const formChanges = useFormChanges(); + const titleId = useGeneratedHtmlId(); + const [showChangePasswordForm, setShowChangePasswordForm] = useState(false); + + const canChangeDetails = canUserChangeDetails(user, services.application.capabilities); + + const rightSideItems = [ + { + title: ( + + ), + description: user.username as string | undefined, + helpText: ( + + ), + testSubj: 'username', + }, + ]; + + if (!canChangeDetails) { + rightSideItems.push({ + title: ( + + ), + description: user.full_name, + helpText: ( + + ), + testSubj: 'full_name', + }); + + 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', position: 'fixed' }} + restrictWidth={1000} + > +
+ + + setShowChangePasswordForm(true)} + /> + + +
+
+
+
+ ); +}; + +export function useUserProfileForm({ user, data }: UserProfileProps) { + const { services } = useKibana(); + const { userProfiles, users } = useSecurityApiClients(); + + 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/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..4787bcacd8662 --- /dev/null +++ b/x-pack/plugins/security/public/components/index.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. + */ + +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 103952d7d34ef..12df907679384 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 { useSecurityApiClients } 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 } = useSecurityApiClients(); + const dataUpdateState = useObservable(userProfiles.dataUpdates$); + return useAsync(() => userProfiles.get(dataPath), [userProfiles, dataUpdateState]); +} diff --git a/x-pack/plugins/security/public/components/user_avatar.tsx b/x-pack/plugins/security/public/components/user_avatar.tsx new file mode 100644 index 0000000000000..ff9bb1055bcc7 --- /dev/null +++ b/x-pack/plugins/security/public/components/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 { UserAvatarData, UserInfo } from '../../common'; +import { + getUserAvatarColor, + getUserAvatarInitials, + getUserDisplayName, + USER_AVATAR_MAX_INITIALS, +} from '../../common/model'; + +export interface UserAvatarProps extends Omit, 'color'> { + user?: Pick; + avatar?: UserAvatarData; + 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/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..beba64e6b2fb9 --- /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..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 @@ -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..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 @@ -5,24 +5,25 @@ * 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 { UserAvatarData } from '../../common'; +import { getUserDisplayName, isUserAnonymous } from '../../common/model'; +import { useCurrentUser, UserAvatar, useUserProfile } from '../components'; export interface UserMenuLink { label: string; @@ -32,174 +33,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: 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) : ''; + + 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 d23ce4ac0ed2e..180446a2e38d2 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', + securityApiClients: 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 +122,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', + securityApiClients: 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 +142,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', + securityApiClients: 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', + securityApiClients: 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 +181,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', + securityApiClients: 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', + securityApiClients: 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 +253,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 +323,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 +346,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..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 @@ -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 { 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; - authc: AuthenticationServiceSetup; logoutUrl: string; + securityApiClients: SecurityApiClients; } 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 securityApiClients!: SecurityApiClients; 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, securityApiClients }: SetupDeps) { this.securityLicense = securityLicense; - this.authc = authc; this.logoutUrl = logoutUrl; + this.securityApiClients = securityApiClients; } - 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,28 @@ 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 +147,28 @@ export class SecurityNavControlService { return sortBy(userMenuLinks, 'order'); } } + +export interface ProvidersProps { + authc: AuthenticationServiceSetup; + services: CoreStart; + securityApiClients: SecurityApiClients; + theme$: Observable; +} + +export const Providers: FunctionComponent = ({ + authc, + services, + theme$, + securityApiClients, + children, +}) => ( + + + + + {children} + + + + +); diff --git a/x-pack/plugins/security/public/plugin.tsx b/x-pack/plugins/security/public/plugin.tsx index 7ca2e2f353efa..f648c4963884e 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 securityApiClients = { + userProfiles: new UserProfileAPIClient(core.http), + users: new UserAPIClient(core.http), + }; + this.navControlService.setup({ securityLicense: license, - authc: this.authc, logoutUrl: getLogoutUrl(core.http), + securityApiClients, }); accountManagementApp.create({ authc: this.authc, application: core.application, getStartServices: core.getStartServices, + securityApiClients, }); 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/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 842689b99a446..62b93a0f0c337 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 '@kbn/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,6 +50,25 @@ interface AuthenticationOptions { user?: AuthenticatedUser; authHeaders?: AuthHeaders; authResponseHeaders?: AuthHeaders; + userProfileGrant?: UserProfileGrant; +} + +export interface SucceededAuthenticationResultOptions { + state?: unknown; + authHeaders?: AuthHeaders; + authResponseHeaders?: AuthHeaders; + userProfileGrant?: UserProfileGrant; +} + +export interface RedirectedAuthenticationResultOptions { + state?: unknown; + user?: AuthenticatedUser; + authResponseHeaders?: AuthHeaders; + userProfileGrant?: UserProfileGrant; +} + +export interface FailedAuthenticationResultOptions { + authResponseHeaders?: AuthHeaders; } /** @@ -66,6 +86,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 +95,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 +107,7 @@ export class AuthenticationResult { return new AuthenticationResult(AuthenticationResultStatus.Succeeded, { user, + userProfileGrant, authHeaders, authResponseHeaders, state, @@ -100,7 +123,7 @@ export class AuthenticationResult { */ public static failed( error: Error, - { authResponseHeaders }: Pick = {} + { authResponseHeaders }: FailedAuthenticationResultOptions = {} ) { if (!error) { throw new Error('Error should be specified.'); @@ -116,6 +139,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 +148,10 @@ export class AuthenticationResult { redirectURL: string, { user, + userProfileGrant, authResponseHeaders, state, - }: Pick = {} + }: RedirectedAuthenticationResultOptions = {} ) { if (!redirectURL) { throw new Error('Redirect URL must be specified.'); @@ -135,18 +160,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 +202,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 +216,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 e071839f20351..fc69971649330 100644 --- a/x-pack/plugins/security/server/authentication/authentication_service.test.ts +++ b/x-pack/plugins/security/server/authentication/authentication_service.test.ts @@ -49,6 +49,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'; @@ -69,9 +70,11 @@ describe('AuthenticationService', () => { http: jest.Mocked; clusterClient: ReturnType; featureUsageService: jest.Mocked; + userProfileService: ReturnType; session: jest.Mocked>; applicationName: 'kibana-.kibana'; kibanaFeatures: []; + isElasticCloudDeployment: jest.Mock; }; beforeEach(() => { logger = loggingSystemMock.createLogger(); @@ -110,8 +113,10 @@ describe('AuthenticationService', () => { loggers: loggingSystemMock.create(), featureUsageService: securityFeatureUsageServiceMock.createStartContract(), session: sessionMock.create(), + 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 0318fe3823a46..50ad8ae2b082b 100644 --- a/x-pack/plugins/security/server/authentication/authentication_service.ts +++ b/x-pack/plugins/security/server/authentication/authentication_service.ts @@ -26,6 +26,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'; @@ -48,10 +49,12 @@ interface AuthenticationServiceStartParams { clusterClient: IClusterClient; audit: AuditServiceSetup; featureUsageService: SecurityFeatureUsageServiceStart; + userProfileService: UserProfileServiceStart; session: PublicMethodsOf; loggers: LoggerFactory; applicationName: string; kibanaFeatures: KibanaFeature[]; + isElasticCloudDeployment: () => boolean; } export interface InternalAuthenticationServiceStart extends AuthenticationServiceStart { @@ -296,11 +299,13 @@ export class AuthenticationService { config, clusterClient, featureUsageService, + userProfileService, http, loggers, session, applicationName, kibanaFeatures, + isElasticCloudDeployment, }: AuthenticationServiceStartParams): InternalAuthenticationServiceStart { const apiKeys = new APIKeys({ clusterClient, @@ -333,9 +338,11 @@ export class AuthenticationService { config: { authc: config.authc }, getCurrentUser, featureUsageService, + userProfileService, 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 3bbb1e5a48be2..43a85603edbb9 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.test.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.test.ts @@ -20,13 +20,14 @@ import { } from '@kbn/core/server/mocks'; import type { PublicMethodsOf } from '@kbn/utility-types'; +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 { userProfileMock } from '../../common/model/user_profile.mock'; import type { AuditLogger } from '../audit'; import { auditLoggerMock, auditServiceMock } from '../audit/mocks'; import { ConfigSchema, createConfig } from '../config'; @@ -34,6 +35,8 @@ 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 { 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,8 @@ function getMockOptions({ ), session: sessionMock.create(), featureUsageService: securityFeatureUsageServiceMock.createStartContract(), + userProfileService: userProfileServiceMock.createStart(), + isElasticCloudDeployment: jest.fn().mockReturnValue(false), }; } @@ -430,6 +435,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 +1290,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 +1315,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 +1464,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 +1492,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 7164b5e98b2f6..7f42bde1e397d 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.ts @@ -10,6 +10,7 @@ import { KibanaRequest } from '@kbn/core/server'; import type { Logger } from '@kbn/logging'; import type { PublicMethodsOf } from '@kbn/utility-types'; +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; @@ -88,6 +89,7 @@ export interface AuthenticatorOptions { clusterClient: IClusterClient; session: PublicMethodsOf; getServerBaseURL: () => string; + isElasticCloudDeployment: () => boolean; } /** @internal */ @@ -231,6 +233,7 @@ export class Authenticator { logger: this.options.loggers.get('tokens'), }), getServerBaseURL: this.options.getServerBaseURL, + isElasticCloudDeployment: this.options.isElasticCloudDeployment, }; this.providers = new Map( @@ -712,16 +715,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/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/basic.test.ts b/x-pack/plugins/security/server/authentication/providers/basic.test.ts index 3fbaa82f15cdc..29bf38c6f1653 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 02094d680fb07..cbdcfbe9a5ead 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 2db1d149962b1..8643386f762b3 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 9b793e886b74c..5cf7ede569e5d 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 097491d61d4ca..c02f7c54c5421 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 44aa56bd2a298..143245b31770b 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 b099ad87460de..0196ec8d99421 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 adba9efd3e81e..0e3544063f1d6 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 07cc8a9d1bb07..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'; @@ -63,6 +64,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 +110,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 +187,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 +229,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 +263,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 +337,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 +356,7 @@ describe('SAMLAuthenticationProvider', () => { }) ).resolves.toEqual( AuthenticationResult.redirectTo(`${mockOptions.basePath.serverBasePath}/`, { + userProfileGrant: { type: 'accessToken', accessToken: 'valid-token' }, state: { accessToken: 'valid-token', refreshToken: 'valid-refresh-token', @@ -359,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: {} }), { @@ -368,6 +413,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 +433,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 +455,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 +480,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 +650,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 +713,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 0adb2fb565996..890e76301ddc7 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 30ca1c2561123..fbdf6e39abff3 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 1bbf6605ff135..d34704c53260b 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/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 07b3e1ea232ec..c470147acb957 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -55,6 +55,8 @@ 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'], @@ -90,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; } /** @@ -188,6 +196,30 @@ export class SecurityPlugin return this.anonymousAccessStart; }; + private readonly userProfileService: UserProfileService; + private userProfileStart?: UserProfileServiceStart; + private readonly getUserProfileService = () => { + if (!this.userProfileStart) { + throw new Error(`userProfileStart is not registered!`); + } + 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(); @@ -205,6 +237,9 @@ export class SecurityPlugin this.initializerContext.logger.get('anonymous-access'), this.getConfig ); + this.userProfileService = new UserProfileService( + this.initializerContext.logger.get('user-profile') + ); } public setup( @@ -311,6 +346,7 @@ export class SecurityPlugin getFeatureUsageService: this.getFeatureUsageService, getAuthenticationService: this.getAuthentication, getAnonymousAccessService: this.getAnonymousAccess, + getUserProfileService: this.getUserProfileService, }); return Object.freeze({ @@ -333,6 +369,7 @@ export class SecurityPlugin license, logger: this.logger.get('deprecations'), }), + setIsElasticCloudDeployment: this.setIsElasticCloudDeployment, }); } @@ -357,17 +394,21 @@ 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, 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 5241c10669dbd..1e5e524796218 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/index.ts b/x-pack/plugins/security/server/routes/index.ts index afb799cf73150..03ca6cff39d1a 100644 --- a/x-pack/plugins/security/server/routes/index.ts +++ b/x-pack/plugins/security/server/routes/index.ts @@ -19,6 +19,7 @@ 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'; import { defineAnonymousAccessRoutes } from './anonymous_access'; import { defineApiKeysRoutes } from './api_keys'; import { defineAuthenticationRoutes } from './authentication'; @@ -28,6 +29,7 @@ import { defineIndicesRoutes } from './indices'; 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'; @@ -47,6 +49,7 @@ export interface RouteDefinitionParams { getFeatures: () => Promise; getFeatureUsageService: () => SecurityFeatureUsageServiceStart; getAuthenticationService: () => InternalAuthenticationServiceStart; + getUserProfileService: () => UserProfileServiceStart; getAnonymousAccessService: () => AnonymousAccessServiceStart; } @@ -57,6 +60,7 @@ export function defineRoutes(params: RouteDefinitionParams) { defineApiKeysRoutes(params); defineIndicesRoutes(params); defineUsersRoutes(params); + defineUserProfileRoutes(params); defineRoleMappingRoutes(params); defineViewRoutes(params); defineDeprecationsRoutes(params); diff --git a/x-pack/plugins/security/server/routes/user_profile/get.ts b/x-pack/plugins/security/server/routes/user_profile/get.ts new file mode 100644 index 0000000000000..8e62da27e050e --- /dev/null +++ b/x-pack/plugins/security/server/routes/user_profile/get.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; + +import type { RouteDefinitionParams } from '..'; +import type { AuthenticatedUserProfile } from '../../../common'; +import { wrapIntoCustomErrorResponse } from '../../errors'; +import { getPrintableSessionId } from '../../session_management'; +import { createLicensedRouteHandler } from '../licensed_route_handler'; + +export function defineGetUserProfileRoute({ + router, + getSession, + getUserProfileService, + logger, +}: RouteDefinitionParams) { + router.get( + { + path: '/internal/security/user_profile', + validate: { + 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: ${getPrintableSessionId(session.sid)})` + ); + return response.notFound(); + } + + const userProfileService = getUserProfileService(); + try { + 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 new file mode 100644 index 0000000000000..6d526d2ce6a75 --- /dev/null +++ b/x-pack/plugins/security/server/routes/user_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 '..'; +import { defineGetUserProfileRoute } from './get'; +import { defineUpdateUserProfileDataRoute } from './update'; + +export function defineUserProfileRoutes(params: RouteDefinitionParams) { + defineUpdateUserProfileDataRoute(params); + defineGetUserProfileRoute(params); +} 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..785b644a3fa12 --- /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 new file mode 100644 index 0000000000000..d4c030d0e0306 --- /dev/null +++ b/x-pack/plugins/security/server/routes/user_profile/update.ts @@ -0,0 +1,62 @@ +/* + * 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 { getPrintableSessionId } from '../../session_management'; +import { createLicensedRouteHandler } from '../licensed_route_handler'; + +export function defineUpdateUserProfileDataRoute({ + router, + getSession, + getUserProfileService, + logger, + getAuthenticationService, +}: RouteDefinitionParams) { + router.post( + { + path: '/internal/security/user_profile/_data', + validate: { + 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: ${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: ${getPrintableSessionId( + session.sid + )})` + ); + return response.forbidden(); + } + + const userProfileService = getUserProfileService(); + try { + 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/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.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 11fbd168780d1..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'; @@ -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 }) @@ -882,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 febeb6db55f76..8f6181ae3bee3 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 { KibanaRequest, Logger } from '@kbn/core/server'; import type { PublicMethodsOf } from '@kbn/utility-types'; -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; } @@ -100,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. @@ -173,7 +189,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 +214,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 +223,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 +254,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 +264,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 +284,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 +470,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 }, + }; } /** @@ -458,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'); } /** 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..dd058107ffa60 --- /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 { UserProfileService } from './user_profile_service'; +export type { + UserProfileServiceStart, + UserProfileServiceStartParams, +} from './user_profile_service'; +export type { UserProfileGrant } from './user_profile_grant'; 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..e6fd72c15f110 --- /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 '.'; +import { userProfileMock } from '../../common/model/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 new file mode 100644 index 0000000000000..a2646c3e957b0 --- /dev/null +++ b/x-pack/plugins/security/server/user_profile/user_profile_service.test.ts @@ -0,0 +1,412 @@ +/* + * 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 { errors } from '@elastic/elasticsearch'; + +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 { UserProfileService } from './user_profile_service'; + +const logger = loggingSystemMock.createLogger(); +const userProfileService = new UserProfileService(logger); + +describe('UserProfileService', () => { + let mockStartParams: { + clusterClient: ReturnType; + }; + + beforeEach(() => { + 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(() => { + logger.error.mockClear(); + }); + + it('should expose correct start contract', () => { + const startContract = userProfileService.start(mockStartParams); + expect(startContract).toMatchInlineSnapshot(` + Object { + "activate": [Function], + "get": [Function], + "update": [Function], + } + `); + }); + + 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, + "authentication_provider": Object { + "name": "basic1", + "type": "basic", + }, + "authentication_realm": Object { + "name": "native1", + "type": "native", + }, + "authentication_type": "realm", + "elastic_cloud_user": false, + "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', + }); + }); + + 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(mockStartParams); + await expect(startContract.get('UID', '*')).resolves.toMatchInlineSnapshot(` + 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", + "elastic_cloud_user": false, + "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.*', + }); + }); + }); + + 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/UID/_data', + }); + }); + + 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(); + }); + }); + + 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, + "authentication_provider": Object { + "name": "basic1", + "type": "basic", + }, + "authentication_realm": Object { + "name": "native1", + "type": "native", + }, + "authentication_type": "realm", + "elastic_cloud_user": false, + "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 + ); + 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, + "authentication_provider": Object { + "name": "basic1", + "type": "basic", + }, + "authentication_realm": Object { + "name": "native1", + "type": "native", + }, + "authentication_type": "realm", + "elastic_cloud_user": false, + "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 + ); + 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 with non-409 error', async () => { + const failureReason = new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 500, 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' }, + }); + }); + + 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", + "elastic_cloud_user": false, + "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 new file mode 100644 index 0000000000000..fcc62f87fd953 --- /dev/null +++ b/x-pack/plugins/security/server/user_profile/user_profile_service.ts @@ -0,0 +1,154 @@ +/* + * 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 { IClusterClient, Logger } from '@kbn/core/server'; + +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 { + /** + * 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 + * @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: UserInfo; + data: { + [KIBANA_DATA_ROOT]: T; + }; + access: {}; + enabled: boolean; + last_synchronized: number; + authentication_provider: AuthenticationProvider; + } +>; + +export interface UserProfileServiceStartParams { + clusterClient: IClusterClient; +} + +export class UserProfileService { + constructor(private readonly logger: Logger) {} + + start({ clusterClient }: UserProfileServiceStartParams): UserProfileServiceStart { + const { logger } = this; + + async function activate(grant: UserProfileGrant): Promise { + logger.debug(`Activating user profile via ${grant.type} grant.`); + + 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) { + try { + const body = await clusterClient.asInternalUser.transport.request>({ + method: 'GET', + path: `_security/profile/${uid}${ + dataPath ? `?data=${KIBANA_DATA_ROOT}.${dataPath}` : '' + }`, + }); + 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; + } + } + + async function update(uid: string, data: T) { + try { + await clusterClient.asInternalUser.transport.request({ + method: 'POST', + path: `_security/profile/${uid}/_data`, + body: { + data: { + [KIBANA_DATA_ROOT]: data, + }, + }, + }); + } catch (error) { + logger.error(`Failed to update user profile [uid=${uid}]: ${error.message}`); + throw error; + } + } + + return { activate, get, update }; + } +} diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 8e7a928cedeca..52e4d54383d2d 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -22828,7 +22828,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.", @@ -23240,22 +23239,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 59d0f4d727ca2..dd389cf7f652e 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -22963,7 +22963,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": "この情報は変更できません。", @@ -23375,22 +23374,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 796445b542ca1..f0d2ad90cd60b 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -22995,7 +22995,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": "不能更改此信息。", @@ -23407,22 +23406,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/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/functional/apps/security/user_email.ts b/x-pack/test/functional/apps/security/user_email.ts index f7878543bf3e7..44c375bdab10d 100644 --- a/x-pack/test/functional/apps/security/user_email.ts +++ b/x-pack/test/functional/apps/security/user_email.ts @@ -45,18 +45,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 04c405b09407c..1bf0e2eea4d70 100644 --- a/x-pack/test/functional/apps/security/users.ts +++ b/x-pack/test/functional/apps/security/users.ts @@ -181,9 +181,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 () => { @@ -202,9 +200,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/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); diff --git a/yarn.lock b/yarn.lock index 369d99179cc15..4cb1bf8a2e7b3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12524,7 +12524,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== @@ -15104,6 +15104,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" @@ -19479,7 +19492,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== @@ -23964,7 +23977,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== @@ -27937,7 +27950,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==