From 987a850f1c40ff579583dda8fd8cb55c6b9861c1 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Fri, 11 Aug 2023 16:49:26 +0200 Subject: [PATCH] Support logging out of Serverless projects. (#163154) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The Serverless MVP project experience will not include support for Single Log-out ("SLO"). This means that the cloud console and Kibana will maintain independent sessions, which must be managed separately. Due to this limitation, the user avatar menu should not feature an explicit "Logout" link, as it could mistakenly suggest that we are terminating their Elastic Session. In reality, we are only invalidating the Kibana session. This PR replace the existing "Logout" link with a "Close project" link, when in Serverless. This link will invalidate the Kibana session (similar to our current “Logout” link) and redirect the user to their Serverless projects list. ~~__Blocked by: https://github.com/elastic/kibana/issues/163379__~~ ## How to test ### Locally ```yaml xpack.cloud.id: "foo" xpack.cloud.base_url: "https://console.qa.cld.elstc.co" xpack.cloud.projects_url: "/projects" ``` ```bash yarn es snapshot --license trial yarn start --serverless ``` ### MKI ```http POST {{host}}/api/v1/serverless/projects/elasticsearch Authorization: ApiKey {{api-key}} Content-Type: application/json { "name": "xxxx", "region_id": "local-k8s", "overrides": { "kibana": { "docker_image": "docker.elastic.co/kibana-ci/kibana-serverless:pr-163154-697c0b749433-arm64" } } } ``` __Fixes: https://github.com/elastic/kibana/issues/162887__ --- .../nav_control_component.test.tsx | 104 +++++++++++++++++- .../nav_control/nav_control_component.tsx | 8 ++ .../nav_control/nav_control_service.test.ts | 12 +- .../nav_control/nav_control_service.tsx | 4 + x-pack/plugins/security/public/plugin.tsx | 5 +- .../authentication_service.test.ts | 2 + .../authentication/authentication_service.ts | 3 + .../authentication/authenticator.test.ts | 30 +++++ .../server/authentication/authenticator.ts | 5 + x-pack/plugins/security/server/plugin.ts | 7 ++ 10 files changed, 168 insertions(+), 12 deletions(-) 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 c09eb20328c49..933eddbaae29b 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 @@ -57,7 +57,12 @@ describe('SecurityNavControl', () => { it('should render an avatar when user profile has loaded', async () => { const wrapper = shallow( - + ); expect(useUserProfileMock).toHaveBeenCalledTimes(1); @@ -106,7 +111,12 @@ describe('SecurityNavControl', () => { }); const wrapper = shallow( - + ); expect(useUserProfileMock).toHaveBeenCalledTimes(1); @@ -134,7 +144,12 @@ describe('SecurityNavControl', () => { it('should open popover when avatar is clicked', async () => { const wrapper = shallow( - + ); act(() => { @@ -154,7 +169,12 @@ describe('SecurityNavControl', () => { }); const wrapper = shallow( - + ); act(() => { @@ -186,6 +206,7 @@ describe('SecurityNavControl', () => { }, ]) } + buildFlavour={'traditional'} /> ); @@ -290,6 +311,7 @@ describe('SecurityNavControl', () => { }, ]) } + buildFlavour={'traditional'} /> ); @@ -352,6 +374,73 @@ describe('SecurityNavControl', () => { `); }); + it('should render `Close project` link when in Serverless', async () => { + const wrapper = shallow( + + ); + + expect(wrapper.find(EuiContextMenu).prop('panels')).toMatchInlineSnapshot(` + Array [ + Object { + "content": , + "name": , + "onClick": [Function], + }, + Object { + "content": undefined, + "data-test-subj": "userMenuLink__link1", + "href": "path-to-link-1", + "icon": , + "name": "link1", + }, + Object { + "data-test-subj": "logoutLink", + "href": "", + "icon": , + "name": , + }, + ] + } + />, + "id": 0, + "title": "full name", + }, + ] + `); + }); + it('should render anonymous user', async () => { useUserProfileMock.mockReturnValue({ loading: false, @@ -367,7 +456,12 @@ describe('SecurityNavControl', () => { }); const wrapper = shallow( - + ); expect(wrapper.find(EuiContextMenu).prop('panels')).toMatchInlineSnapshot(` 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 13bcb3bcb4341..b2f05f9c6d568 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 @@ -20,6 +20,7 @@ import React, { Fragment, useState } from 'react'; import useObservable from 'react-use/lib/useObservable'; import type { Observable } from 'rxjs'; +import type { BuildFlavor } from '@kbn/config/src/types'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { UserAvatar, type UserProfileAvatarData } from '@kbn/user-profile-components'; @@ -72,12 +73,14 @@ interface SecurityNavControlProps { editProfileUrl: string; logoutUrl: string; userMenuLinks$: Observable; + buildFlavour: BuildFlavor; } export const SecurityNavControl: FunctionComponent = ({ editProfileUrl, logoutUrl, userMenuLinks$, + buildFlavour, }) => { const userMenuLinks = useObservable(userMenuLinks$, []); const [isPopoverOpen, setIsPopoverOpen] = useState(false); @@ -157,6 +160,11 @@ export const SecurityNavControl: FunctionComponent = ({ id="xpack.security.navControlComponent.loginLinkText" defaultMessage="Log in" /> + ) : buildFlavour === 'serverless' ? ( + ) : ( { const license$ = new BehaviorSubject(validLicense); const coreStart = coreMock.createStart(); - const navControlService = new SecurityNavControlService(); + const navControlService = new SecurityNavControlService('traditional'); navControlService.setup({ securityLicense: new SecurityLicenseService().setup({ license$ }).license, logoutUrl: '/some/logout/url', @@ -128,7 +128,7 @@ describe('SecurityNavControlService', () => { const license$ = new BehaviorSubject({} as ILicense); const coreStart = coreMock.createStart(); - const navControlService = new SecurityNavControlService(); + const navControlService = new SecurityNavControlService('traditional'); navControlService.setup({ securityLicense: new SecurityLicenseService().setup({ license$ }).license, logoutUrl: '/some/logout/url', @@ -148,7 +148,7 @@ describe('SecurityNavControlService', () => { const license$ = new BehaviorSubject(validLicense); const coreStart = coreMock.createStart(); - const navControlService = new SecurityNavControlService(); + const navControlService = new SecurityNavControlService('traditional'); navControlService.setup({ securityLicense: new SecurityLicenseService().setup({ license$ }).license, logoutUrl: '/some/logout/url', @@ -165,7 +165,7 @@ describe('SecurityNavControlService', () => { const license$ = new BehaviorSubject(validLicense); const coreStart = coreMock.createStart(); - const navControlService = new SecurityNavControlService(); + const navControlService = new SecurityNavControlService('traditional'); navControlService.setup({ securityLicense: new SecurityLicenseService().setup({ license$ }).license, logoutUrl: '/some/logout/url', @@ -187,7 +187,7 @@ describe('SecurityNavControlService', () => { const license$ = new BehaviorSubject(validLicense); const coreStart = coreMock.createStart(); - const navControlService = new SecurityNavControlService(); + const navControlService = new SecurityNavControlService('traditional'); navControlService.setup({ securityLicense: new SecurityLicenseService().setup({ license$ }).license, logoutUrl: '/some/logout/url', @@ -210,7 +210,7 @@ describe('SecurityNavControlService', () => { const coreSetup = coreMock.createSetup(); const license$ = new BehaviorSubject({} as ILicense); - navControlService = new SecurityNavControlService(); + navControlService = new SecurityNavControlService('traditional'); navControlService.setup({ securityLicense: new SecurityLicenseService().setup({ license$ }).license, logoutUrl: '/some/logout/url', 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 de87a5ea16625..0bcc3a58263fb 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 @@ -13,6 +13,7 @@ import type { Observable, Subscription } from 'rxjs'; import { BehaviorSubject, ReplaySubject } from 'rxjs'; import { map, takeUntil } from 'rxjs/operators'; +import type { BuildFlavor } from '@kbn/config/src/types'; import type { CoreStart, CoreTheme } from '@kbn/core/public'; import { I18nProvider } from '@kbn/i18n-react'; import { KibanaContextProvider, KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; @@ -60,6 +61,8 @@ export class SecurityNavControlService { private readonly stop$ = new ReplaySubject(1); private userMenuLinks$ = new BehaviorSubject([]); + constructor(private readonly buildFlavor: BuildFlavor) {} + public setup({ securityLicense, logoutUrl, securityApiClients }: SetupDeps) { this.securityLicense = securityLicense; this.logoutUrl = logoutUrl; @@ -133,6 +136,7 @@ export class SecurityNavControlService { editProfileUrl={core.http.basePath.prepend('/security/account')} logoutUrl={this.logoutUrl} userMenuLinks$={this.userMenuLinks$} + buildFlavour={this.buildFlavor} /> , element diff --git a/x-pack/plugins/security/public/plugin.tsx b/x-pack/plugins/security/public/plugin.tsx index 2c9c4025129e4..eb5b2723f9eab 100644 --- a/x-pack/plugins/security/public/plugin.tsx +++ b/x-pack/plugins/security/public/plugin.tsx @@ -68,7 +68,7 @@ export class SecurityPlugin private readonly config: ConfigType; private sessionTimeout?: SessionTimeout; private readonly authenticationService = new AuthenticationService(); - private readonly navControlService = new SecurityNavControlService(); + private readonly navControlService; private readonly securityLicenseService = new SecurityLicenseService(); private readonly managementService = new ManagementService(); private readonly securityCheckupService: SecurityCheckupService; @@ -80,6 +80,9 @@ export class SecurityPlugin constructor(private readonly initializerContext: PluginInitializerContext) { this.config = this.initializerContext.config.get(); this.securityCheckupService = new SecurityCheckupService(this.config, localStorage); + this.navControlService = new SecurityNavControlService( + initializerContext.env.packageInfo.buildFlavor + ); } public setup( 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 7f79b8c7d54ba..4e81f0e4a5f13 100644 --- a/x-pack/plugins/security/server/authentication/authentication_service.test.ts +++ b/x-pack/plugins/security/server/authentication/authentication_service.test.ts @@ -78,6 +78,7 @@ describe('AuthenticationService', () => { applicationName: 'kibana-.kibana'; kibanaFeatures: []; isElasticCloudDeployment: jest.Mock; + customLogoutURL?: string; }; beforeEach(() => { logger = loggingSystemMock.createLogger(); @@ -121,6 +122,7 @@ describe('AuthenticationService', () => { applicationName: 'kibana-.kibana', kibanaFeatures: [], isElasticCloudDeployment: jest.fn().mockReturnValue(false), + customLogoutURL: 'https://some-logout-origin/logout', }; (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 171b7ae4212b3..a26ac8943ee78 100644 --- a/x-pack/plugins/security/server/authentication/authentication_service.ts +++ b/x-pack/plugins/security/server/authentication/authentication_service.ts @@ -57,6 +57,7 @@ interface AuthenticationServiceStartParams { applicationName: string; kibanaFeatures: KibanaFeature[]; isElasticCloudDeployment: () => boolean; + customLogoutURL?: string; } export interface InternalAuthenticationServiceStart extends AuthenticationServiceStart { @@ -328,6 +329,7 @@ export class AuthenticationService { applicationName, kibanaFeatures, isElasticCloudDeployment, + customLogoutURL, }: AuthenticationServiceStartParams): InternalAuthenticationServiceStart { const apiKeys = new APIKeys({ clusterClient, @@ -368,6 +370,7 @@ export class AuthenticationService { license: this.license, session, isElasticCloudDeployment, + customLogoutURL, }); return { diff --git a/x-pack/plugins/security/server/authentication/authenticator.test.ts b/x-pack/plugins/security/server/authentication/authenticator.test.ts index f0e1479362fdf..2de2fdbf4df2a 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.test.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.test.ts @@ -61,11 +61,13 @@ function getMockOptions({ http = {}, selector, accessAgreementMessage, + customLogoutURL, }: { providers?: Record | string[]; http?: Partial; selector?: AuthenticatorOptions['config']['authc']['selector']; accessAgreementMessage?: string; + customLogoutURL?: string; } = {}) { const auditService = auditServiceMock.create(); auditLogger = auditLoggerMock.create(); @@ -95,6 +97,7 @@ function getMockOptions({ featureUsageService: securityFeatureUsageServiceMock.createStartContract(), userProfileService: userProfileServiceMock.createStart(), isElasticCloudDeployment: jest.fn().mockReturnValue(false), + customLogoutURL, }; } @@ -249,6 +252,33 @@ describe('Authenticator', () => { '/mock-server-basepath/security/logged_out?next=%2Fapp%2Fml%2Fencode+me&msg=SESSION_EXPIRED' ); }); + + it('points to a custom URL if `customLogoutURL` is specified', () => { + const authenticationProviderMock = + jest.requireMock(`./providers/saml`).SAMLAuthenticationProvider; + authenticationProviderMock.mockClear(); + new Authenticator( + getMockOptions({ + selector: { enabled: false }, + providers: { saml: { saml1: { order: 0, realm: 'realm' } } }, + customLogoutURL: 'https://some-logout-origin/logout', + }) + ); + const getLoggedOutURL = authenticationProviderMock.mock.calls[0][0].urls.loggedOut; + + expect(getLoggedOutURL(httpServerMock.createKibanaRequest())).toBe( + 'https://some-logout-origin/logout' + ); + + // We don't forward any Kibana specific query string parameters to the external logout URL. + expect( + getLoggedOutURL( + httpServerMock.createKibanaRequest({ + query: { next: '/app/ml/encode me', msg: 'SESSION_EXPIRED' }, + }) + ) + ).toBe('https://some-logout-origin/logout'); + }); }); describe('HTTP authentication provider', () => { diff --git a/x-pack/plugins/security/server/authentication/authenticator.ts b/x-pack/plugins/security/server/authentication/authenticator.ts index 3d1ffebabb513..24329c0e7575f 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.ts @@ -97,6 +97,7 @@ export interface AuthenticatorOptions { session: PublicMethodsOf; getServerBaseURL: () => string; isElasticCloudDeployment: () => boolean; + customLogoutURL?: string; } /** @internal */ @@ -1013,6 +1014,10 @@ export class Authenticator { * provider in the chain (default) is assumed. */ private getLoggedOutURL(request: KibanaRequest, providerType?: string) { + if (this.options.customLogoutURL) { + return this.options.customLogoutURL; + } + // The app that handles logout needs to know the reason of the logout and the URL we may need to // redirect user to once they log in again (e.g. when session expires). const searchParams = new URLSearchParams(); diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index a022fdd418e6e..4f36c0bf508d0 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -408,6 +408,12 @@ export class SecurityPlugin this.userProfileStart = this.userProfileService.start({ clusterClient, session }); this.userSettingServiceStart = this.userSettingService.start(this.userProfileStart); + // In serverless, we want to redirect users to the list of projects instead of standard "Logged Out" page. + const customLogoutURL = + this.initializerContext.env.packageInfo.buildFlavor === 'serverless' + ? cloud?.projectsUrl + : undefined; + const config = this.getConfig(); this.authenticationStart = this.authenticationService.start({ audit: this.auditSetup!, @@ -421,6 +427,7 @@ export class SecurityPlugin applicationName: this.authorizationSetup!.applicationName, kibanaFeatures: features.getKibanaFeatures(), isElasticCloudDeployment: () => cloud?.isCloudEnabled === true, + customLogoutURL, }); this.authorizationService.start({