diff --git a/x-pack/plugins/cloud/public/plugin.test.ts b/x-pack/plugins/cloud/public/plugin.test.ts index 36be9e590f216..f48195dbc83f0 100644 --- a/x-pack/plugins/cloud/public/plugin.test.ts +++ b/x-pack/plugins/cloud/public/plugin.test.ts @@ -169,13 +169,10 @@ describe('Cloud Plugin', () => { expect(hashId1).not.toEqual(hashId2); }); - test('user hash does not include cloudId when authenticated via Cloud SAML', async () => { + test('user hash does not include cloudId when user is an Elastic Cloud user', async () => { const { coreSetup } = await setupPlugin({ config: { id: 'cloudDeploymentId' }, - currentUserProps: { - username, - authentication_realm: { type: 'saml', name: 'cloud-saml-kibana' }, - }, + currentUserProps: { username, elastic_cloud_user: true }, }); expect(coreSetup.analytics.registerContextProvider).toHaveBeenCalled(); diff --git a/x-pack/plugins/cloud/public/plugin.tsx b/x-pack/plugins/cloud/public/plugin.tsx index 1bccf219225dc..c9c0cb1c4933c 100644 --- a/x-pack/plugins/cloud/public/plugin.tsx +++ b/x-pack/plugins/cloud/public/plugin.tsx @@ -262,12 +262,8 @@ export class CloudPlugin implements Plugin { name: 'cloud_user_id', context$: from(security.authc.getCurrentUser()).pipe( map((user) => { - if ( - getIsCloudEnabled(cloudId) && - user.authentication_realm?.type === 'saml' && - user.authentication_realm?.name === 'cloud-saml-kibana' - ) { - // If authenticated via Cloud SAML, use the SAML username as the user ID + if (user.elastic_cloud_user) { + // If authenticated via Elastic Cloud SSO, use the username as the user ID return user.username; } diff --git a/x-pack/plugins/cloud/server/plugin.test.ts b/x-pack/plugins/cloud/server/plugin.test.ts new file mode 100644 index 0000000000000..ccb0b8545fcf6 --- /dev/null +++ b/x-pack/plugins/cloud/server/plugin.test.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { coreMock } from '@kbn/core/server/mocks'; +import { CloudPlugin } from './plugin'; +import { config } from './config'; +import { securityMock } from '@kbn/security-plugin/server/mocks'; +import { usageCollectionPluginMock } from '@kbn/usage-collection-plugin/server/mocks'; + +describe('Cloud Plugin', () => { + describe('#setup', () => { + describe('setupSecurity', () => { + it('properly handles missing optional Security dependency if Cloud ID is NOT set.', async () => { + const plugin = new CloudPlugin( + coreMock.createPluginInitializerContext(config.schema.validate({})) + ); + + expect(() => + plugin.setup(coreMock.createSetup(), { + usageCollection: usageCollectionPluginMock.createSetupContract(), + }) + ).not.toThrow(); + }); + + it('properly handles missing optional Security dependency if Cloud ID is set.', async () => { + const plugin = new CloudPlugin( + coreMock.createPluginInitializerContext(config.schema.validate({ id: 'my-cloud' })) + ); + + expect(() => + plugin.setup(coreMock.createSetup(), { + usageCollection: usageCollectionPluginMock.createSetupContract(), + }) + ).not.toThrow(); + }); + + it('does not notify Security plugin about Cloud environment if Cloud ID is NOT set.', async () => { + const plugin = new CloudPlugin( + coreMock.createPluginInitializerContext(config.schema.validate({})) + ); + + const securityDependencyMock = securityMock.createSetup(); + plugin.setup(coreMock.createSetup(), { + security: securityDependencyMock, + usageCollection: usageCollectionPluginMock.createSetupContract(), + }); + + expect(securityDependencyMock.setIsElasticCloudDeployment).not.toHaveBeenCalled(); + }); + + it('properly notifies Security plugin about Cloud environment if Cloud ID is set.', async () => { + const plugin = new CloudPlugin( + coreMock.createPluginInitializerContext(config.schema.validate({ id: 'my-cloud' })) + ); + + const securityDependencyMock = securityMock.createSetup(); + plugin.setup(coreMock.createSetup(), { + security: securityDependencyMock, + usageCollection: usageCollectionPluginMock.createSetupContract(), + }); + + expect(securityDependencyMock.setIsElasticCloudDeployment).toHaveBeenCalledTimes(1); + }); + }); + }); +}); diff --git a/x-pack/plugins/cloud/server/plugin.ts b/x-pack/plugins/cloud/server/plugin.ts index 2cbb41531ecf5..8d5c38477d0cb 100644 --- a/x-pack/plugins/cloud/server/plugin.ts +++ b/x-pack/plugins/cloud/server/plugin.ts @@ -50,6 +50,10 @@ export class CloudPlugin implements Plugin { registerCloudDeploymentIdAnalyticsContext(core.analytics, this.config.id); registerCloudUsageCollector(usageCollection, { isCloudEnabled }); + if (isCloudEnabled) { + security?.setIsElasticCloudDeployment(); + } + if (this.config.full_story.enabled) { registerFullstoryRoute({ httpResources: core.http.resources, diff --git a/x-pack/plugins/security/common/model/authenticated_user.mock.ts b/x-pack/plugins/security/common/model/authenticated_user.mock.ts index cb7d64fe79786..73641d2fa5983 100644 --- a/x-pack/plugins/security/common/model/authenticated_user.mock.ts +++ b/x-pack/plugins/security/common/model/authenticated_user.mock.ts @@ -23,6 +23,7 @@ export function mockAuthenticatedUser(user: MockAuthenticatedUserProps = {}) { lookup_realm: { name: 'native1', type: 'native' }, authentication_provider: { type: 'basic', name: 'basic1' }, authentication_type: 'realm', + elastic_cloud_user: false, metadata: { _reserved: false }, ...user, }; diff --git a/x-pack/plugins/security/common/model/authenticated_user.ts b/x-pack/plugins/security/common/model/authenticated_user.ts index 708cb00fbca50..2237384791e8b 100644 --- a/x-pack/plugins/security/common/model/authenticated_user.ts +++ b/x-pack/plugins/security/common/model/authenticated_user.ts @@ -42,6 +42,11 @@ export interface AuthenticatedUser extends User { * @example "realm" | "api_key" | "token" | "anonymous" | "internal" */ authentication_type: string; + + /** + * Indicates whether user is authenticated via Elastic Cloud built-in SAML realm. + */ + elastic_cloud_user: boolean; } export function isUserAnonymous(user: Pick) { diff --git a/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx b/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx index 24d4594091d5f..20f293f37fc26 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx +++ b/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx @@ -83,6 +83,7 @@ describe('SecurityNavControl', () => { "type": "native", }, "authentication_type": "realm", + "elastic_cloud_user": false, "email": "email", "enabled": true, "full_name": "full name", diff --git a/x-pack/plugins/security/server/authentication/authentication_service.test.ts b/x-pack/plugins/security/server/authentication/authentication_service.test.ts index ce4cef5ee6cf7..fc69971649330 100644 --- a/x-pack/plugins/security/server/authentication/authentication_service.test.ts +++ b/x-pack/plugins/security/server/authentication/authentication_service.test.ts @@ -74,6 +74,7 @@ describe('AuthenticationService', () => { session: jest.Mocked>; applicationName: 'kibana-.kibana'; kibanaFeatures: []; + isElasticCloudDeployment: jest.Mock; }; beforeEach(() => { logger = loggingSystemMock.createLogger(); @@ -115,6 +116,7 @@ describe('AuthenticationService', () => { userProfileService: userProfileServiceMock.createStart(), applicationName: 'kibana-.kibana', kibanaFeatures: [], + isElasticCloudDeployment: jest.fn().mockReturnValue(false), }; (mockStartAuthenticationParams.http.basePath.get as jest.Mock).mockImplementation( () => mockStartAuthenticationParams.http.basePath.serverBasePath diff --git a/x-pack/plugins/security/server/authentication/authentication_service.ts b/x-pack/plugins/security/server/authentication/authentication_service.ts index 1ab3eaccef203..50ad8ae2b082b 100644 --- a/x-pack/plugins/security/server/authentication/authentication_service.ts +++ b/x-pack/plugins/security/server/authentication/authentication_service.ts @@ -54,6 +54,7 @@ interface AuthenticationServiceStartParams { loggers: LoggerFactory; applicationName: string; kibanaFeatures: KibanaFeature[]; + isElasticCloudDeployment: () => boolean; } export interface InternalAuthenticationServiceStart extends AuthenticationServiceStart { @@ -304,6 +305,7 @@ export class AuthenticationService { session, applicationName, kibanaFeatures, + isElasticCloudDeployment, }: AuthenticationServiceStartParams): InternalAuthenticationServiceStart { const apiKeys = new APIKeys({ clusterClient, @@ -340,6 +342,7 @@ export class AuthenticationService { getServerBaseURL, license: this.license, session, + isElasticCloudDeployment, }); return { diff --git a/x-pack/plugins/security/server/authentication/authenticator.test.ts b/x-pack/plugins/security/server/authentication/authenticator.test.ts index 0959479e8023f..43a85603edbb9 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.test.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.test.ts @@ -72,6 +72,7 @@ function getMockOptions({ session: sessionMock.create(), featureUsageService: securityFeatureUsageServiceMock.createStartContract(), userProfileService: userProfileServiceMock.createStart(), + isElasticCloudDeployment: jest.fn().mockReturnValue(false), }; } diff --git a/x-pack/plugins/security/server/authentication/authenticator.ts b/x-pack/plugins/security/server/authentication/authenticator.ts index a813c1ca80d6c..7f42bde1e397d 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.ts @@ -89,6 +89,7 @@ export interface AuthenticatorOptions { clusterClient: IClusterClient; session: PublicMethodsOf; getServerBaseURL: () => string; + isElasticCloudDeployment: () => boolean; } /** @internal */ @@ -232,6 +233,7 @@ export class Authenticator { logger: this.options.loggers.get('tokens'), }), getServerBaseURL: this.options.getServerBaseURL, + isElasticCloudDeployment: this.options.isElasticCloudDeployment, }; this.providers = new Map( diff --git a/x-pack/plugins/security/server/authentication/providers/base.mock.ts b/x-pack/plugins/security/server/authentication/providers/base.mock.ts index 2abe3cf6277a5..af7537ca15074 100644 --- a/x-pack/plugins/security/server/authentication/providers/base.mock.ts +++ b/x-pack/plugins/security/server/authentication/providers/base.mock.ts @@ -27,5 +27,6 @@ export function mockAuthenticationProviderOptions(options?: { name: string }) { urls: { loggedOut: jest.fn().mockReturnValue('/mock-server-basepath/security/logged_out'), }, + isElasticCloudDeployment: jest.fn().mockReturnValue(false), }; } diff --git a/x-pack/plugins/security/server/authentication/providers/base.ts b/x-pack/plugins/security/server/authentication/providers/base.ts index a344243ba97a7..ccf9ecba71f36 100644 --- a/x-pack/plugins/security/server/authentication/providers/base.ts +++ b/x-pack/plugins/security/server/authentication/providers/base.ts @@ -38,6 +38,7 @@ export interface AuthenticationProviderOptions { urls: { loggedOut: (request: KibanaRequest) => string; }; + isElasticCloudDeployment: () => boolean; } /** @@ -45,6 +46,11 @@ export interface AuthenticationProviderOptions { */ export type AuthenticationProviderSpecificOptions = Record; +/** + * Name of the Elastic Cloud built-in SSO realm. + */ +export const ELASTIC_CLOUD_SSO_REALM_NAME = 'cloud-saml-kibana'; + /** * Base class that all authentication providers should extend. */ @@ -133,6 +139,10 @@ export abstract class BaseAuthenticationProvider { return deepFreeze({ ...authenticationInfo, authentication_provider: { type: this.type, name: this.options.name }, + elastic_cloud_user: + this.options.isElasticCloudDeployment() && + authenticationInfo.authentication_realm.type === 'saml' && + authenticationInfo.authentication_realm.name === ELASTIC_CLOUD_SSO_REALM_NAME, } as AuthenticatedUser); } } diff --git a/x-pack/plugins/security/server/authentication/providers/saml.test.ts b/x-pack/plugins/security/server/authentication/providers/saml.test.ts index 3fe22ee621065..a165b1960c3f3 100644 --- a/x-pack/plugins/security/server/authentication/providers/saml.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/saml.test.ts @@ -18,6 +18,7 @@ import { mockAuthenticatedUser } from '../../../common/model/authenticated_user. import { securityMock } from '../../mocks'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; +import { ELASTIC_CLOUD_SSO_REALM_NAME } from './base'; import type { MockAuthenticationProviderOptions } from './base.mock'; import { mockAuthenticationProviderOptions } from './base.mock'; import { SAMLAuthenticationProvider, SAMLLogin } from './saml'; @@ -366,6 +367,43 @@ describe('SAMLAuthenticationProvider', () => { ); }); + it('recognizes Elastic Cloud users.', async () => { + const nonElasticCloudUser = mockAuthenticatedUser({ + authentication_provider: { type: 'saml', name: 'saml' }, + authentication_realm: { type: 'saml', name: 'random-saml' }, + }); + const elasticCloudUser = mockAuthenticatedUser({ + authentication_provider: { type: 'saml', name: 'saml' }, + authentication_realm: { type: 'saml', name: ELASTIC_CLOUD_SSO_REALM_NAME }, + }); + + // The only case when user should be recognized as Elastic Cloud user: Kibana is running inside Cloud + // deployment and user is authenticated with SAML realm of the predefined name. + for (const [authentication, isElasticCloudDeployment, isElasticCloudUser] of [ + [nonElasticCloudUser, false, false], + [nonElasticCloudUser, true, false], + [elasticCloudUser, false, false], + [elasticCloudUser, true, true], + ]) { + mockOptions.client.asInternalUser.transport.request.mockResolvedValue({ + username: 'user', + access_token: 'valid-token', + refresh_token: 'valid-refresh-token', + realm: 'test-realm', + authentication, + }); + + mockOptions.isElasticCloudDeployment.mockReturnValue(isElasticCloudDeployment); + + const loginResult = await provider.login( + httpServerMock.createKibanaRequest({ headers: {} }), + { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml' } + ); + + expect(loginResult.user?.elastic_cloud_user).toBe(isElasticCloudUser); + } + }); + it('redirects to the home page if `relayState` includes external URL', async () => { await expect( provider.login(httpServerMock.createKibanaRequest({ headers: {} }), { diff --git a/x-pack/plugins/security/server/elasticsearch/index.ts b/x-pack/plugins/security/server/elasticsearch/index.ts index 46acb874f7fc2..239802028b122 100644 --- a/x-pack/plugins/security/server/elasticsearch/index.ts +++ b/x-pack/plugins/security/server/elasticsearch/index.ts @@ -7,7 +7,10 @@ import type { AuthenticatedUser } from '../../common/model'; -export type AuthenticationInfo = Omit; +export type AuthenticationInfo = Omit< + AuthenticatedUser, + 'authentication_provider' | 'elastic_cloud_user' +>; export type { ElasticsearchServiceStart, OnlineStatusRetryScheduler, diff --git a/x-pack/plugins/security/server/mocks.ts b/x-pack/plugins/security/server/mocks.ts index de484647ffb6d..d126efb4d79bd 100644 --- a/x-pack/plugins/security/server/mocks.ts +++ b/x-pack/plugins/security/server/mocks.ts @@ -31,6 +31,7 @@ function createSetupMock() { privilegeDeprecationsService: { getKibanaRolesByFeatureId: jest.fn(), }, + setIsElasticCloudDeployment: jest.fn(), }; } diff --git a/x-pack/plugins/security/server/plugin.test.ts b/x-pack/plugins/security/server/plugin.test.ts index c1fddb1751189..663f0f16ea260 100644 --- a/x-pack/plugins/security/server/plugin.test.ts +++ b/x-pack/plugins/security/server/plugin.test.ts @@ -124,9 +124,18 @@ describe('Security Plugin', () => { "privilegeDeprecationsService": Object { "getKibanaRolesByFeatureId": [Function], }, + "setIsElasticCloudDeployment": [Function], } `); }); + + it('#setIsElasticCloudDeployment cannot be called twice', () => { + const { setIsElasticCloudDeployment } = plugin.setup(mockCoreSetup, mockSetupDependencies); + setIsElasticCloudDeployment(); + expect(() => setIsElasticCloudDeployment()).toThrowErrorMatchingInlineSnapshot( + `"The Elastic Cloud deployment flag has been set already!"` + ); + }); }); describe('start()', () => { diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index fb476168d4cbe..c470147acb957 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -92,6 +92,12 @@ export interface SecurityPluginSetup { * Exposes services to access kibana roles per feature id with the GetDeprecationsContext */ privilegeDeprecationsService: PrivilegeDeprecationsService; + + /** + * Sets the flag to indicate that Kibana is running inside an Elastic Cloud deployment. This flag is supposed to be + * set by the Cloud plugin and can be only once. + */ + setIsElasticCloudDeployment: () => void; } /** @@ -199,6 +205,21 @@ export class SecurityPlugin return this.userProfileStart; }; + /** + * Indicates whether Kibana is running inside an Elastic Cloud deployment. Since circular plugin dependencies are + * forbidden, this flag is supposed to be set by the Cloud plugin that already depends on the Security plugin. + * @private + */ + private isElasticCloudDeployment?: boolean; + private readonly getIsElasticCloudDeployment = () => this.isElasticCloudDeployment === true; + private readonly setIsElasticCloudDeployment = () => { + if (this.isElasticCloudDeployment !== undefined) { + throw new Error(`The Elastic Cloud deployment flag has been set already!`); + } + + this.isElasticCloudDeployment = true; + }; + constructor(private readonly initializerContext: PluginInitializerContext) { this.logger = this.initializerContext.logger.get(); @@ -348,6 +369,7 @@ export class SecurityPlugin license, logger: this.logger.get('deprecations'), }), + setIsElasticCloudDeployment: this.setIsElasticCloudDeployment, }); } @@ -386,6 +408,7 @@ export class SecurityPlugin session, applicationName: this.authorizationSetup!.applicationName, kibanaFeatures: features.getKibanaFeatures(), + isElasticCloudDeployment: this.getIsElasticCloudDeployment, }); this.authorizationService.start({ diff --git a/x-pack/plugins/security/server/routes/index.mock.ts b/x-pack/plugins/security/server/routes/index.mock.ts index 3cda2a0ec9bc5..21bbd47db20b5 100644 --- a/x-pack/plugins/security/server/routes/index.mock.ts +++ b/x-pack/plugins/security/server/routes/index.mock.ts @@ -23,6 +23,7 @@ import { authorizationMock } from '../authorization/index.mock'; import { ConfigSchema, createConfig } from '../config'; import { sessionMock } from '../session_management/session.mock'; import type { SecurityRequestHandlerContext } from '../types'; +import { userProfileServiceMock } from '../user_profile/user_profile_service.mock'; export const routeDefinitionParamsMock = { create: (rawConfig: Record = {}) => { @@ -46,6 +47,7 @@ export const routeDefinitionParamsMock = { getSession: jest.fn().mockReturnValue(sessionMock.create()), getAuthenticationService: jest.fn().mockReturnValue(authenticationServiceMock.createStart()), getAnonymousAccessService: jest.fn(), + getUserProfileService: jest.fn().mockReturnValue(userProfileServiceMock.createStart()), } as unknown as DeeplyMockedKeys; }, }; diff --git a/x-pack/plugins/security/server/routes/user_profile/update.test.ts b/x-pack/plugins/security/server/routes/user_profile/update.test.ts new file mode 100644 index 0000000000000..a842194a7eba3 --- /dev/null +++ b/x-pack/plugins/security/server/routes/user_profile/update.test.ts @@ -0,0 +1,150 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ObjectType } from '@kbn/config-schema'; +import type { RequestHandler, RouteConfig } from '@kbn/core/server'; +import { kibanaResponseFactory } from '@kbn/core/server'; +import { httpServerMock } from '@kbn/core/server/mocks'; +import type { PublicMethodsOf } from '@kbn/utility-types'; +import type { DeeplyMockedKeys } from '@kbn/utility-types/jest'; + +import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; +import type { InternalAuthenticationServiceStart } from '../../authentication'; +import { authenticationServiceMock } from '../../authentication/authentication_service.mock'; +import type { Session } from '../../session_management'; +import { sessionMock } from '../../session_management/session.mock'; +import type { SecurityRequestHandlerContext, SecurityRouter } from '../../types'; +import type { UserProfileServiceStart } from '../../user_profile'; +import { userProfileServiceMock } from '../../user_profile/user_profile_service.mock'; +import { routeDefinitionParamsMock } from '../index.mock'; +import { defineUpdateUserProfileDataRoute } from './update'; + +function getMockContext() { + return { + licensing: { + license: { check: jest.fn().mockReturnValue({ check: 'valid' }) }, + }, + } as unknown as SecurityRequestHandlerContext; +} + +describe('Update profile routes', () => { + let router: jest.Mocked; + let session: jest.Mocked>; + let userProfileService: jest.Mocked; + let authc: DeeplyMockedKeys; + beforeEach(() => { + const routeParamsMock = routeDefinitionParamsMock.create(); + router = routeParamsMock.router; + + session = sessionMock.create(); + routeParamsMock.getSession.mockReturnValue(session); + + userProfileService = userProfileServiceMock.createStart(); + routeParamsMock.getUserProfileService.mockReturnValue(userProfileService); + + authc = authenticationServiceMock.createStart(); + routeParamsMock.getAuthenticationService.mockReturnValue(authc); + + defineUpdateUserProfileDataRoute(routeParamsMock); + }); + + describe('update profile data', () => { + let routeHandler: RequestHandler; + let routeConfig: RouteConfig; + beforeEach(() => { + const [updateRouteConfig, updateRouteHandler] = router.post.mock.calls.find( + ([{ path }]) => path === '/internal/security/user_profile/_data' + )!; + + routeConfig = updateRouteConfig; + routeHandler = updateRouteHandler; + }); + + it('correctly defines route.', () => { + const bodySchema = (routeConfig.validate as any).body as ObjectType; + expect(() => bodySchema.validate(0)).toThrowErrorMatchingInlineSnapshot( + `"expected value of type [object] but got [number]"` + ); + expect(() => bodySchema.validate('avatar')).toThrowErrorMatchingInlineSnapshot( + `"could not parse record value from json input"` + ); + expect(() => bodySchema.validate(true)).toThrowErrorMatchingInlineSnapshot( + `"expected value of type [object] but got [boolean]"` + ); + expect(() => bodySchema.validate(null)).toThrowErrorMatchingInlineSnapshot( + `"expected value of type [object] but got [null]"` + ); + expect(() => bodySchema.validate(undefined)).toThrowErrorMatchingInlineSnapshot( + `"expected value of type [object] but got [undefined]"` + ); + + expect(bodySchema.validate({})).toEqual({}); + expect( + bodySchema.validate({ title: 'some-title', content: { deepProperty: { type: 'basic' } } }) + ).toEqual({ title: 'some-title', content: { deepProperty: { type: 'basic' } } }); + }); + + it('fails if session is not found.', async () => { + session.get.mockResolvedValue(null); + + await expect( + routeHandler( + getMockContext(), + httpServerMock.createKibanaRequest({ body: {} }), + kibanaResponseFactory + ) + ).resolves.toEqual(expect.objectContaining({ status: 404 })); + + expect(userProfileService.update).not.toHaveBeenCalled(); + }); + + it('fails if session does not have profile ID.', async () => { + session.get.mockResolvedValue(sessionMock.createValue({ userProfileId: undefined })); + + await expect( + routeHandler( + getMockContext(), + httpServerMock.createKibanaRequest({ body: {} }), + kibanaResponseFactory + ) + ).resolves.toEqual(expect.objectContaining({ status: 404 })); + + expect(userProfileService.update).not.toHaveBeenCalled(); + }); + + it('fails for Elastic Cloud users.', async () => { + session.get.mockResolvedValue(sessionMock.createValue()); + authc.getCurrentUser.mockReturnValue(mockAuthenticatedUser({ elastic_cloud_user: true })); + + await expect( + routeHandler( + getMockContext(), + httpServerMock.createKibanaRequest({ body: {} }), + kibanaResponseFactory + ) + ).resolves.toEqual(expect.objectContaining({ status: 403 })); + + expect(userProfileService.update).not.toHaveBeenCalled(); + }); + + it('updates profile.', async () => { + session.get.mockResolvedValue(sessionMock.createValue({ userProfileId: 'u_some_id' })); + authc.getCurrentUser.mockReturnValue(mockAuthenticatedUser()); + + await expect( + routeHandler( + getMockContext(), + httpServerMock.createKibanaRequest({ body: { some: 'property' } }), + kibanaResponseFactory + ) + ).resolves.toEqual(expect.objectContaining({ status: 200, payload: undefined })); + + expect(userProfileService.update).toBeCalledTimes(1); + expect(userProfileService.update).toBeCalledWith('u_some_id', { some: 'property' }); + }); + }); +}); diff --git a/x-pack/plugins/security/server/routes/user_profile/update.ts b/x-pack/plugins/security/server/routes/user_profile/update.ts index 333821508e96e..d6f4726e4b7d5 100644 --- a/x-pack/plugins/security/server/routes/user_profile/update.ts +++ b/x-pack/plugins/security/server/routes/user_profile/update.ts @@ -16,6 +16,7 @@ export function defineUpdateUserProfileDataRoute({ getSession, getUserProfileService, logger, + getAuthenticationService, }: RouteDefinitionParams) { router.post( { @@ -36,6 +37,16 @@ export function defineUpdateUserProfileDataRoute({ return response.notFound(); } + const currentUser = getAuthenticationService().getCurrentUser(request); + if (currentUser?.elastic_cloud_user) { + logger.warn( + `Elastic Cloud SSO users aren't allowed to update profiles in Kibana. (sid: ${session.sid.slice( + -10 + )})` + ); + return response.forbidden(); + } + const userProfileService = getUserProfileService(); try { await userProfileService.update(session.userProfileId, request.body); diff --git a/x-pack/plugins/security/server/user_profile/user_profile_service.test.ts b/x-pack/plugins/security/server/user_profile/user_profile_service.test.ts index 2da6ec8de944b..a2646c3e957b0 100644 --- a/x-pack/plugins/security/server/user_profile/user_profile_service.test.ts +++ b/x-pack/plugins/security/server/user_profile/user_profile_service.test.ts @@ -79,6 +79,7 @@ describe('UserProfileService', () => { "type": "native", }, "authentication_type": "realm", + "elastic_cloud_user": false, "email": "email", "enabled": true, "full_name": "full name", @@ -129,6 +130,7 @@ describe('UserProfileService', () => { "type": "native", }, "authentication_type": "realm", + "elastic_cloud_user": false, "email": "email", "enabled": true, "full_name": "full name", @@ -215,6 +217,7 @@ describe('UserProfileService', () => { "type": "native", }, "authentication_type": "realm", + "elastic_cloud_user": false, "email": "email", "enabled": true, "full_name": "full name", @@ -259,6 +262,7 @@ describe('UserProfileService', () => { "type": "native", }, "authentication_type": "realm", + "elastic_cloud_user": false, "email": "email", "enabled": true, "full_name": "full name", @@ -340,6 +344,7 @@ describe('UserProfileService', () => { "type": "native", }, "authentication_type": "realm", + "elastic_cloud_user": false, "email": "email", "enabled": true, "full_name": "full name", diff --git a/x-pack/test/api_integration/apis/security/basic_login.js b/x-pack/test/api_integration/apis/security/basic_login.js index ea8971d620231..c81034b6fb824 100644 --- a/x-pack/test/api_integration/apis/security/basic_login.js +++ b/x-pack/test/api_integration/apis/security/basic_login.js @@ -146,6 +146,7 @@ export default function ({ getService }) { 'lookup_realm', 'authentication_provider', 'authentication_type', + 'elastic_cloud_user', ]); expect(apiResponse.body.username).to.be(validUsername); expect(apiResponse.body.authentication_provider).to.eql({ type: 'http', name: '__http__' }); @@ -192,6 +193,7 @@ export default function ({ getService }) { 'lookup_realm', 'authentication_provider', 'authentication_type', + 'elastic_cloud_user', ]); expect(apiResponse.body.username).to.be(validUsername); expect(apiResponse.body.authentication_provider).to.eql({ type: 'basic', name: 'basic' }); diff --git a/x-pack/test/security_api_integration/tests/http_bearer/header.ts b/x-pack/test/security_api_integration/tests/http_bearer/header.ts index 7de7d83e154e2..1cc080c3b3e77 100644 --- a/x-pack/test/security_api_integration/tests/http_bearer/header.ts +++ b/x-pack/test/security_api_integration/tests/http_bearer/header.ts @@ -27,6 +27,7 @@ export default function ({ getService }: FtrProviderContext) { ...authentication, authentication_provider: { name: '__http__', type: 'http' }, authentication_type: 'token', + elastic_cloud_user: false, }, }; } diff --git a/x-pack/test/security_api_integration/tests/kerberos/kerberos_login.ts b/x-pack/test/security_api_integration/tests/kerberos/kerberos_login.ts index 9eeb951c04e5c..091dfb1fb5ccc 100644 --- a/x-pack/test/security_api_integration/tests/kerberos/kerberos_login.ts +++ b/x-pack/test/security_api_integration/tests/kerberos/kerberos_login.ts @@ -157,6 +157,7 @@ export default function ({ getService }: FtrProviderContext) { lookup_realm: { name: 'kerb1', type: 'kerberos' }, authentication_provider: { type: 'kerberos', name: 'kerberos' }, authentication_type: 'token', + elastic_cloud_user: false, }); }); diff --git a/x-pack/test/security_api_integration/tests/login_selector/basic_functionality.ts b/x-pack/test/security_api_integration/tests/login_selector/basic_functionality.ts index 2666277780be7..df7d309261a38 100644 --- a/x-pack/test/security_api_integration/tests/login_selector/basic_functionality.ts +++ b/x-pack/test/security_api_integration/tests/login_selector/basic_functionality.ts @@ -64,6 +64,7 @@ export default function ({ getService }: FtrProviderContext) { 'lookup_realm', 'authentication_provider', 'authentication_type', + 'elastic_cloud_user', ]); expect(apiResponse.body.username).to.be(username); diff --git a/x-pack/test/security_api_integration/tests/oidc/authorization_code_flow/oidc_auth.ts b/x-pack/test/security_api_integration/tests/oidc/authorization_code_flow/oidc_auth.ts index 86d8fa2f77d2d..a5fad51792d30 100644 --- a/x-pack/test/security_api_integration/tests/oidc/authorization_code_flow/oidc_auth.ts +++ b/x-pack/test/security_api_integration/tests/oidc/authorization_code_flow/oidc_auth.ts @@ -225,6 +225,7 @@ export default function ({ getService }: FtrProviderContext) { 'lookup_realm', 'authentication_provider', 'authentication_type', + 'elastic_cloud_user', ]); expect(apiResponse.body.username).to.be('user1'); @@ -278,6 +279,7 @@ export default function ({ getService }: FtrProviderContext) { 'lookup_realm', 'authentication_provider', 'authentication_type', + 'elastic_cloud_user', ]); expect(apiResponse.body.username).to.be('user2'); diff --git a/x-pack/test/security_api_integration/tests/oidc/implicit_flow/oidc_auth.ts b/x-pack/test/security_api_integration/tests/oidc/implicit_flow/oidc_auth.ts index b71430193a14b..9a0483d5cebaa 100644 --- a/x-pack/test/security_api_integration/tests/oidc/implicit_flow/oidc_auth.ts +++ b/x-pack/test/security_api_integration/tests/oidc/implicit_flow/oidc_auth.ts @@ -152,6 +152,7 @@ export default function ({ getService }: FtrProviderContext) { 'lookup_realm', 'authentication_provider', 'authentication_type', + 'elastic_cloud_user', ]); expect(apiResponse.body.username).to.be('user1'); diff --git a/x-pack/test/security_api_integration/tests/pki/pki_auth.ts b/x-pack/test/security_api_integration/tests/pki/pki_auth.ts index 1158a8a4d7fa7..ae9f3d893534b 100644 --- a/x-pack/test/security_api_integration/tests/pki/pki_auth.ts +++ b/x-pack/test/security_api_integration/tests/pki/pki_auth.ts @@ -155,6 +155,7 @@ export default function ({ getService }: FtrProviderContext) { lookup_realm: { name: 'pki1', type: 'pki' }, authentication_provider: { name: 'pki', type: 'pki' }, authentication_type: 'token', + elastic_cloud_user: false, }); }); @@ -192,6 +193,7 @@ export default function ({ getService }: FtrProviderContext) { lookup_realm: { name: 'pki1', type: 'pki' }, authentication_provider: { name: 'pki', type: 'pki' }, authentication_type: 'realm', + elastic_cloud_user: false, }); checkCookieIsSet(parseCookie(response.headers['set-cookie'][0])!); diff --git a/x-pack/test/security_api_integration/tests/saml/saml_login.ts b/x-pack/test/security_api_integration/tests/saml/saml_login.ts index f7b3c732e72ea..998e906a47415 100644 --- a/x-pack/test/security_api_integration/tests/saml/saml_login.ts +++ b/x-pack/test/security_api_integration/tests/saml/saml_login.ts @@ -63,6 +63,7 @@ export default function ({ getService }: FtrProviderContext) { 'lookup_realm', 'authentication_provider', 'authentication_type', + 'elastic_cloud_user', ]); expect(apiResponse.body.username).to.be(username);