Skip to content

Commit

Permalink
Support logging out of Serverless projects. (#163154)
Browse files Browse the repository at this point in the history
## 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__
  • Loading branch information
azasypkin authored Aug 11, 2023
1 parent 169d197 commit 987a850
Show file tree
Hide file tree
Showing 10 changed files with 168 additions and 12 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,12 @@ describe('SecurityNavControl', () => {

it('should render an avatar when user profile has loaded', async () => {
const wrapper = shallow(
<SecurityNavControl editProfileUrl="" logoutUrl="" userMenuLinks$={userMenuLinks$} />
<SecurityNavControl
editProfileUrl=""
logoutUrl=""
userMenuLinks$={userMenuLinks$}
buildFlavour={'traditional'}
/>
);

expect(useUserProfileMock).toHaveBeenCalledTimes(1);
Expand Down Expand Up @@ -106,7 +111,12 @@ describe('SecurityNavControl', () => {
});

const wrapper = shallow(
<SecurityNavControl editProfileUrl="" logoutUrl="" userMenuLinks$={userMenuLinks$} />
<SecurityNavControl
editProfileUrl=""
logoutUrl=""
userMenuLinks$={userMenuLinks$}
buildFlavour={'traditional'}
/>
);

expect(useUserProfileMock).toHaveBeenCalledTimes(1);
Expand Down Expand Up @@ -134,7 +144,12 @@ describe('SecurityNavControl', () => {

it('should open popover when avatar is clicked', async () => {
const wrapper = shallow(
<SecurityNavControl editProfileUrl="" logoutUrl="" userMenuLinks$={userMenuLinks$} />
<SecurityNavControl
editProfileUrl=""
logoutUrl=""
userMenuLinks$={userMenuLinks$}
buildFlavour={'traditional'}
/>
);

act(() => {
Expand All @@ -154,7 +169,12 @@ describe('SecurityNavControl', () => {
});

const wrapper = shallow(
<SecurityNavControl editProfileUrl="" logoutUrl="" userMenuLinks$={userMenuLinks$} />
<SecurityNavControl
editProfileUrl=""
logoutUrl=""
userMenuLinks$={userMenuLinks$}
buildFlavour={'traditional'}
/>
);

act(() => {
Expand Down Expand Up @@ -186,6 +206,7 @@ describe('SecurityNavControl', () => {
},
])
}
buildFlavour={'traditional'}
/>
);

Expand Down Expand Up @@ -290,6 +311,7 @@ describe('SecurityNavControl', () => {
},
])
}
buildFlavour={'traditional'}
/>
);

Expand Down Expand Up @@ -352,6 +374,73 @@ describe('SecurityNavControl', () => {
`);
});

it('should render `Close project` link when in Serverless', async () => {
const wrapper = shallow(
<SecurityNavControl
editProfileUrl="edit-profile-link"
logoutUrl=""
userMenuLinks$={
new BehaviorSubject([
{ label: 'link1', href: 'path-to-link-1', iconType: 'empty', order: 1 },
])
}
buildFlavour={'serverless'}
/>
);

expect(wrapper.find(EuiContextMenu).prop('panels')).toMatchInlineSnapshot(`
Array [
Object {
"content": <ContextMenuContent
items={
Array [
Object {
"data-test-subj": "profileLink",
"href": "edit-profile-link",
"icon": <EuiIcon
size="m"
type="user"
/>,
"name": <FormattedMessage
defaultMessage="Edit profile"
id="xpack.security.navControlComponent.editProfileLinkText"
values={Object {}}
/>,
"onClick": [Function],
},
Object {
"content": undefined,
"data-test-subj": "userMenuLink__link1",
"href": "path-to-link-1",
"icon": <EuiIcon
size="m"
type="empty"
/>,
"name": "link1",
},
Object {
"data-test-subj": "logoutLink",
"href": "",
"icon": <EuiIcon
size="m"
type="exit"
/>,
"name": <FormattedMessage
defaultMessage="Close project"
id="xpack.security.navControlComponent.closeProjectLinkText"
values={Object {}}
/>,
},
]
}
/>,
"id": 0,
"title": "full name",
},
]
`);
});

it('should render anonymous user', async () => {
useUserProfileMock.mockReturnValue({
loading: false,
Expand All @@ -367,7 +456,12 @@ describe('SecurityNavControl', () => {
});

const wrapper = shallow(
<SecurityNavControl editProfileUrl="" logoutUrl="" userMenuLinks$={userMenuLinks$} />
<SecurityNavControl
editProfileUrl=""
logoutUrl=""
userMenuLinks$={userMenuLinks$}
buildFlavour={'traditional'}
/>
);

expect(wrapper.find(EuiContextMenu).prop('panels')).toMatchInlineSnapshot(`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -72,12 +73,14 @@ interface SecurityNavControlProps {
editProfileUrl: string;
logoutUrl: string;
userMenuLinks$: Observable<UserMenuLink[]>;
buildFlavour: BuildFlavor;
}

export const SecurityNavControl: FunctionComponent<SecurityNavControlProps> = ({
editProfileUrl,
logoutUrl,
userMenuLinks$,
buildFlavour,
}) => {
const userMenuLinks = useObservable(userMenuLinks$, []);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
Expand Down Expand Up @@ -157,6 +160,11 @@ export const SecurityNavControl: FunctionComponent<SecurityNavControlProps> = ({
id="xpack.security.navControlComponent.loginLinkText"
defaultMessage="Log in"
/>
) : buildFlavour === 'serverless' ? (
<FormattedMessage
id="xpack.security.navControlComponent.closeProjectLinkText"
defaultMessage="Close project"
/>
) : (
<FormattedMessage
id="xpack.security.navControlComponent.logoutLinkText"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ describe('SecurityNavControlService', () => {
const license$ = new BehaviorSubject<ILicense>(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',
Expand Down Expand Up @@ -128,7 +128,7 @@ describe('SecurityNavControlService', () => {
const license$ = new BehaviorSubject<ILicense>({} 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',
Expand All @@ -148,7 +148,7 @@ describe('SecurityNavControlService', () => {
const license$ = new BehaviorSubject<ILicense>(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',
Expand All @@ -165,7 +165,7 @@ describe('SecurityNavControlService', () => {
const license$ = new BehaviorSubject<ILicense>(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',
Expand All @@ -187,7 +187,7 @@ describe('SecurityNavControlService', () => {
const license$ = new BehaviorSubject<ILicense>(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',
Expand All @@ -210,7 +210,7 @@ describe('SecurityNavControlService', () => {
const coreSetup = coreMock.createSetup();
const license$ = new BehaviorSubject<ILicense>({} as ILicense);

navControlService = new SecurityNavControlService();
navControlService = new SecurityNavControlService('traditional');
navControlService.setup({
securityLicense: new SecurityLicenseService().setup({ license$ }).license,
logoutUrl: '/some/logout/url',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -60,6 +61,8 @@ export class SecurityNavControlService {
private readonly stop$ = new ReplaySubject<void>(1);
private userMenuLinks$ = new BehaviorSubject<UserMenuLink[]>([]);

constructor(private readonly buildFlavor: BuildFlavor) {}

public setup({ securityLicense, logoutUrl, securityApiClients }: SetupDeps) {
this.securityLicense = securityLicense;
this.logoutUrl = logoutUrl;
Expand Down Expand Up @@ -133,6 +136,7 @@ export class SecurityNavControlService {
editProfileUrl={core.http.basePath.prepend('/security/account')}
logoutUrl={this.logoutUrl}
userMenuLinks$={this.userMenuLinks$}
buildFlavour={this.buildFlavor}
/>
</Providers>,
element
Expand Down
5 changes: 4 additions & 1 deletion x-pack/plugins/security/public/plugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -80,6 +80,9 @@ export class SecurityPlugin
constructor(private readonly initializerContext: PluginInitializerContext) {
this.config = this.initializerContext.config.get<ConfigType>();
this.securityCheckupService = new SecurityCheckupService(this.config, localStorage);
this.navControlService = new SecurityNavControlService(
initializerContext.env.packageInfo.buildFlavor
);
}

public setup(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ describe('AuthenticationService', () => {
applicationName: 'kibana-.kibana';
kibanaFeatures: [];
isElasticCloudDeployment: jest.Mock;
customLogoutURL?: string;
};
beforeEach(() => {
logger = loggingSystemMock.createLogger();
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ interface AuthenticationServiceStartParams {
applicationName: string;
kibanaFeatures: KibanaFeature[];
isElasticCloudDeployment: () => boolean;
customLogoutURL?: string;
}

export interface InternalAuthenticationServiceStart extends AuthenticationServiceStart {
Expand Down Expand Up @@ -328,6 +329,7 @@ export class AuthenticationService {
applicationName,
kibanaFeatures,
isElasticCloudDeployment,
customLogoutURL,
}: AuthenticationServiceStartParams): InternalAuthenticationServiceStart {
const apiKeys = new APIKeys({
clusterClient,
Expand Down Expand Up @@ -368,6 +370,7 @@ export class AuthenticationService {
license: this.license,
session,
isElasticCloudDeployment,
customLogoutURL,
});

return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,13 @@ function getMockOptions({
http = {},
selector,
accessAgreementMessage,
customLogoutURL,
}: {
providers?: Record<string, unknown> | string[];
http?: Partial<AuthenticatorOptions['config']['authc']['http']>;
selector?: AuthenticatorOptions['config']['authc']['selector'];
accessAgreementMessage?: string;
customLogoutURL?: string;
} = {}) {
const auditService = auditServiceMock.create();
auditLogger = auditLoggerMock.create();
Expand Down Expand Up @@ -95,6 +97,7 @@ function getMockOptions({
featureUsageService: securityFeatureUsageServiceMock.createStartContract(),
userProfileService: userProfileServiceMock.createStart(),
isElasticCloudDeployment: jest.fn().mockReturnValue(false),
customLogoutURL,
};
}

Expand Down Expand Up @@ -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', () => {
Expand Down
Loading

0 comments on commit 987a850

Please sign in to comment.