From ec8ff3a7fc2a21aa6d28e57a84544a1e99b6e974 Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Fri, 23 Apr 2021 11:59:11 -0400 Subject: [PATCH] Only add cloud-specific links for superusers (#97870) Co-authored-by: Aleh Zasypkin Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/plugins/cloud/public/plugin.test.ts | 190 ++++++++++++++++++ x-pack/plugins/cloud/public/plugin.ts | 64 ++++-- .../common/model/authenticated_user.mock.ts | 6 +- x-pack/plugins/security/public/mocks.ts | 4 + x-pack/plugins/security/server/mocks.ts | 4 + 5 files changed, 251 insertions(+), 17 deletions(-) create mode 100644 x-pack/plugins/cloud/public/plugin.test.ts diff --git a/x-pack/plugins/cloud/public/plugin.test.ts b/x-pack/plugins/cloud/public/plugin.test.ts new file mode 100644 index 000000000000..5a66e6cf9c52 --- /dev/null +++ b/x-pack/plugins/cloud/public/plugin.test.ts @@ -0,0 +1,190 @@ +/* + * 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 { nextTick } from '@kbn/test/jest'; +import { coreMock } from 'src/core/public/mocks'; +import { homePluginMock } from 'src/plugins/home/public/mocks'; +import { securityMock } from '../../security/public/mocks'; +import { CloudPlugin } from './plugin'; + +describe('Cloud Plugin', () => { + describe('#start', () => { + function setupPlugin({ + roles = [], + simulateUserError = false, + }: { roles?: string[]; simulateUserError?: boolean } = {}) { + const plugin = new CloudPlugin( + coreMock.createPluginInitializerContext({ + id: 'cloudId', + base_url: 'https://cloud.elastic.co', + deployment_url: '/abc123', + profile_url: '/profile/alice', + organization_url: '/org/myOrg', + }) + ); + const coreSetup = coreMock.createSetup(); + const homeSetup = homePluginMock.createSetupContract(); + const securitySetup = securityMock.createSetup(); + if (simulateUserError) { + securitySetup.authc.getCurrentUser.mockRejectedValue(new Error('Something happened')); + } else { + securitySetup.authc.getCurrentUser.mockResolvedValue( + securityMock.createMockAuthenticatedUser({ + roles, + }) + ); + } + + plugin.setup(coreSetup, { home: homeSetup, security: securitySetup }); + + return { coreSetup, securitySetup, plugin }; + } + + it('registers help support URL', async () => { + const { plugin } = setupPlugin(); + + const coreStart = coreMock.createStart(); + const securityStart = securityMock.createStart(); + plugin.start(coreStart, { security: securityStart }); + + expect(coreStart.chrome.setHelpSupportUrl).toHaveBeenCalledTimes(1); + expect(coreStart.chrome.setHelpSupportUrl.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "https://support.elastic.co/", + ] + `); + }); + + it('registers a custom nav link for superusers', async () => { + const { plugin } = setupPlugin({ roles: ['superuser'] }); + + const coreStart = coreMock.createStart(); + const securityStart = securityMock.createStart(); + plugin.start(coreStart, { security: securityStart }); + + await nextTick(); + + expect(coreStart.chrome.setCustomNavLink).toHaveBeenCalledTimes(1); + expect(coreStart.chrome.setCustomNavLink.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "euiIconType": "arrowLeft", + "href": "https://cloud.elastic.co/abc123", + "title": "Manage this deployment", + }, + ] + `); + }); + + it('registers a custom nav link when there is an error retrieving the current user', async () => { + const { plugin } = setupPlugin({ simulateUserError: true }); + + const coreStart = coreMock.createStart(); + const securityStart = securityMock.createStart(); + plugin.start(coreStart, { security: securityStart }); + + await nextTick(); + + expect(coreStart.chrome.setCustomNavLink).toHaveBeenCalledTimes(1); + expect(coreStart.chrome.setCustomNavLink.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "euiIconType": "arrowLeft", + "href": "https://cloud.elastic.co/abc123", + "title": "Manage this deployment", + }, + ] + `); + }); + + it('does not register a custom nav link for non-superusers', async () => { + const { plugin } = setupPlugin({ roles: ['not-a-superuser'] }); + + const coreStart = coreMock.createStart(); + const securityStart = securityMock.createStart(); + plugin.start(coreStart, { security: securityStart }); + + await nextTick(); + + expect(coreStart.chrome.setCustomNavLink).not.toHaveBeenCalled(); + }); + + it('registers user profile links for superusers', async () => { + const { plugin } = setupPlugin({ roles: ['superuser'] }); + + const coreStart = coreMock.createStart(); + const securityStart = securityMock.createStart(); + plugin.start(coreStart, { security: securityStart }); + + await nextTick(); + + expect(securityStart.navControlService.addUserMenuLinks).toHaveBeenCalledTimes(1); + expect(securityStart.navControlService.addUserMenuLinks.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "href": "https://cloud.elastic.co/profile/alice", + "iconType": "user", + "label": "Profile", + "order": 100, + "setAsProfile": true, + }, + Object { + "href": "https://cloud.elastic.co/org/myOrg", + "iconType": "gear", + "label": "Account & Billing", + "order": 200, + }, + ], + ] + `); + }); + + it('registers profile links when there is an error retrieving the current user', async () => { + const { plugin } = setupPlugin({ simulateUserError: true }); + + const coreStart = coreMock.createStart(); + const securityStart = securityMock.createStart(); + plugin.start(coreStart, { security: securityStart }); + + await nextTick(); + + expect(securityStart.navControlService.addUserMenuLinks).toHaveBeenCalledTimes(1); + expect(securityStart.navControlService.addUserMenuLinks.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "href": "https://cloud.elastic.co/profile/alice", + "iconType": "user", + "label": "Profile", + "order": 100, + "setAsProfile": true, + }, + Object { + "href": "https://cloud.elastic.co/org/myOrg", + "iconType": "gear", + "label": "Account & Billing", + "order": 200, + }, + ], + ] + `); + }); + + it('does not register profile links for non-superusers', async () => { + const { plugin } = setupPlugin({ roles: ['not-a-superuser'] }); + + const coreStart = coreMock.createStart(); + const securityStart = securityMock.createStart(); + plugin.start(coreStart, { security: securityStart }); + + await nextTick(); + + expect(securityStart.navControlService.addUserMenuLinks).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/cloud/public/plugin.ts b/x-pack/plugins/cloud/public/plugin.ts index 8ca4f7711811..8ba0dfdc8b08 100644 --- a/x-pack/plugins/cloud/public/plugin.ts +++ b/x-pack/plugins/cloud/public/plugin.ts @@ -7,7 +7,7 @@ import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'src/core/public'; import { i18n } from '@kbn/i18n'; -import { SecurityPluginStart } from '../../security/public'; +import { AuthenticatedUser, SecurityPluginSetup, SecurityPluginStart } from '../../security/public'; import { getIsCloudEnabled } from '../common/is_cloud_enabled'; import { ELASTIC_SUPPORT_LINK } from '../common/constants'; import { HomePublicPluginSetup } from '../../../../src/plugins/home/public'; @@ -25,6 +25,7 @@ export interface CloudConfigType { interface CloudSetupDependencies { home?: HomePublicPluginSetup; + security?: Pick; } interface CloudStartDependencies { @@ -44,13 +45,14 @@ export interface CloudSetup { export class CloudPlugin implements Plugin { private config!: CloudConfigType; private isCloudEnabled: boolean; + private authenticatedUserPromise?: Promise; constructor(private readonly initializerContext: PluginInitializerContext) { this.config = this.initializerContext.config.get(); this.isCloudEnabled = false; } - public setup(core: CoreSetup, { home }: CloudSetupDependencies) { + public setup(core: CoreSetup, { home, security }: CloudSetupDependencies) { const { id, cname, @@ -68,6 +70,10 @@ export class CloudPlugin implements Plugin { } } + if (security) { + this.authenticatedUserPromise = security.authc.getCurrentUser().catch(() => null); + } + return { cloudId: id, cname, @@ -82,19 +88,47 @@ export class CloudPlugin implements Plugin { public start(coreStart: CoreStart, { security }: CloudStartDependencies) { const { deployment_url: deploymentUrl, base_url: baseUrl } = this.config; coreStart.chrome.setHelpSupportUrl(ELASTIC_SUPPORT_LINK); - if (baseUrl && deploymentUrl) { - coreStart.chrome.setCustomNavLink({ - title: i18n.translate('xpack.cloud.deploymentLinkLabel', { - defaultMessage: 'Manage this deployment', - }), - euiIconType: 'arrowLeft', - href: getFullCloudUrl(baseUrl, deploymentUrl), - }); - } - if (security && this.isCloudEnabled) { - const userMenuLinks = createUserMenuLinks(this.config); - security.navControlService.addUserMenuLinks(userMenuLinks); - } + const setLinks = (authorized: boolean) => { + if (!authorized) return; + + if (baseUrl && deploymentUrl) { + coreStart.chrome.setCustomNavLink({ + title: i18n.translate('xpack.cloud.deploymentLinkLabel', { + defaultMessage: 'Manage this deployment', + }), + euiIconType: 'arrowLeft', + href: getFullCloudUrl(baseUrl, deploymentUrl), + }); + } + + if (security && this.isCloudEnabled) { + const userMenuLinks = createUserMenuLinks(this.config); + security.navControlService.addUserMenuLinks(userMenuLinks); + } + }; + + this.checkIfAuthorizedForLinks() + .then(setLinks) + // In the event of an unexpected error, fail *open*. + // Cloud admin console will always perform the actual authorization checks. + .catch(() => setLinks(true)); + } + + /** + * Determines if the current user should see links back to Cloud. + * This isn't a true authorization check, but rather a heuristic to + * see if the current user is *likely* a cloud deployment administrator. + * + * At this point, we do not have enough information to reliably make this determination, + * but we do know that all cloud deployment admins are superusers by default. + */ + private async checkIfAuthorizedForLinks() { + // Security plugin is disabled + if (!this.authenticatedUserPromise) return true; + // Otherwise check roles. If user is not defined due to an unexpected error, then fail *open*. + // Cloud admin console will always perform the actual authorization checks. + const user = await this.authenticatedUserPromise; + return user?.roles.includes('superuser') ?? true; } } 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 6dad3886401a..cb7d64fe7978 100644 --- a/x-pack/plugins/security/common/model/authenticated_user.mock.ts +++ b/x-pack/plugins/security/common/model/authenticated_user.mock.ts @@ -9,8 +9,10 @@ import type { AuthenticatedUser } from './authenticated_user'; // We omit `roles` here since the original interface defines this field as `readonly string[]` that makes it hard to use // in various mocks that expect mutable string array. -type AuthenticatedUserProps = Partial & { roles: string[] }>; -export function mockAuthenticatedUser(user: AuthenticatedUserProps = {}) { +export type MockAuthenticatedUserProps = Partial< + Omit & { roles: string[] } +>; +export function mockAuthenticatedUser(user: MockAuthenticatedUserProps = {}) { return { username: 'user', email: 'email', diff --git a/x-pack/plugins/security/public/mocks.ts b/x-pack/plugins/security/public/mocks.ts index 0502025e9bae..cac556d04031 100644 --- a/x-pack/plugins/security/public/mocks.ts +++ b/x-pack/plugins/security/public/mocks.ts @@ -6,6 +6,8 @@ */ import { licenseMock } from '../common/licensing/index.mock'; +import type { MockAuthenticatedUserProps } from '../common/model/authenticated_user.mock'; +import { mockAuthenticatedUser } from '../common/model/authenticated_user.mock'; import { authenticationMock } from './authentication/index.mock'; import { navControlServiceMock } from './nav_control/index.mock'; import { createSessionTimeoutMock } from './session/session_timeout.mock'; @@ -26,4 +28,6 @@ function createStartMock() { export const securityMock = { createSetup: createSetupMock, createStart: createStartMock, + createMockAuthenticatedUser: (props: MockAuthenticatedUserProps = {}) => + mockAuthenticatedUser(props), }; diff --git a/x-pack/plugins/security/server/mocks.ts b/x-pack/plugins/security/server/mocks.ts index 07f60ceb890f..c30fcd8b6960 100644 --- a/x-pack/plugins/security/server/mocks.ts +++ b/x-pack/plugins/security/server/mocks.ts @@ -8,6 +8,8 @@ import type { ApiResponse } from '@elastic/elasticsearch'; import { licenseMock } from '../common/licensing/index.mock'; +import type { MockAuthenticatedUserProps } from '../common/model/authenticated_user.mock'; +import { mockAuthenticatedUser } from '../common/model/authenticated_user.mock'; import { auditServiceMock } from './audit/index.mock'; import { authenticationServiceMock } from './authentication/authentication_service.mock'; import { authorizationMock } from './authorization/index.mock'; @@ -62,4 +64,6 @@ export const securityMock = { createSetup: createSetupMock, createStart: createStartMock, createApiResponse: createApiResponseMock, + createMockAuthenticatedUser: (props: MockAuthenticatedUserProps = {}) => + mockAuthenticatedUser(props), };