diff --git a/x-pack/plugins/enterprise_search/kibana.json b/x-pack/plugins/enterprise_search/kibana.json index 3121d6bd470b05..bfd36b1736d680 100644 --- a/x-pack/plugins/enterprise_search/kibana.json +++ b/x-pack/plugins/enterprise_search/kibana.json @@ -2,9 +2,9 @@ "id": "enterpriseSearch", "version": "1.0.0", "kibanaVersion": "kibana", - "requiredPlugins": ["home", "licensing"], + "requiredPlugins": ["home", "features", "licensing"], "configPath": ["enterpriseSearch"], - "optionalPlugins": ["usageCollection"], + "optionalPlugins": ["usageCollection", "security"], "server": true, "ui": true } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.test.tsx index b76cc73a996b45..12bf0035641039 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.test.tsx @@ -8,12 +8,7 @@ import '../../../__mocks__/shallow_usecontext.mock'; import React from 'react'; import { shallow } from 'enzyme'; -import { EuiEmptyPrompt, EuiButton, EuiCode, EuiLoadingContent } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { shallowWithIntl } from '../../../__mocks__'; - -jest.mock('../../../shared/get_username', () => ({ getUserName: jest.fn() })); -import { getUserName } from '../../../shared/get_username'; +import { EuiEmptyPrompt, EuiButton, EuiLoadingContent } from '@elastic/eui'; jest.mock('../../../shared/telemetry', () => ({ sendTelemetry: jest.fn(), @@ -21,7 +16,7 @@ jest.mock('../../../shared/telemetry', () => ({ })); import { sendTelemetry } from '../../../shared/telemetry'; -import { ErrorState, NoUserState, EmptyState, LoadingState } from './'; +import { ErrorState, EmptyState, LoadingState } from './'; describe('ErrorState', () => { it('renders', () => { @@ -31,24 +26,6 @@ describe('ErrorState', () => { }); }); -describe('NoUserState', () => { - it('renders', () => { - const wrapper = shallow(); - - expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); - }); - - it('renders with username', () => { - (getUserName as jest.Mock).mockImplementationOnce(() => 'dolores-abernathy'); - - const wrapper = shallowWithIntl(); - const prompt = wrapper.find(EuiEmptyPrompt).dive(); - const description1 = prompt.find(FormattedMessage).at(1).dive(); - - expect(description1.find(EuiCode).prop('children')).toContain('dolores-abernathy'); - }); -}); - describe('EmptyState', () => { it('renders', () => { const wrapper = shallow(); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/index.ts index d1b65a4729a870..e92bf214c4cc75 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/index.ts @@ -6,5 +6,4 @@ export { LoadingState } from './loading_state'; export { EmptyState } from './empty_state'; -export { NoUserState } from './no_user_state'; export { ErrorState } from './error_state'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/no_user_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/no_user_state.tsx deleted file mode 100644 index b86b3caceefcab..00000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/no_user_state.tsx +++ /dev/null @@ -1,65 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { EuiPage, EuiPageBody, EuiPageContent, EuiEmptyPrompt, EuiCode } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; - -import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; -import { SendAppSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; -import { getUserName } from '../../../shared/get_username'; -import { EngineOverviewHeader } from '../engine_overview_header'; - -import './empty_states.scss'; - -export const NoUserState: React.FC = () => { - const username = getUserName(); - - return ( - - - - - - - - - - - } - titleSize="l" - body={ - <> -

- {username} : '', - }} - /> -

-

- -

- - } - /> -
-
-
- ); -}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx index 18cf3dade20568..4d2a2ea1df9aa9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx @@ -15,7 +15,7 @@ import { KibanaContext } from '../../../'; import { LicenseContext } from '../../../shared/licensing'; import { mountWithContext, mockKibanaContext } from '../../../__mocks__'; -import { EmptyState, ErrorState, NoUserState } from '../empty_states'; +import { EmptyState, ErrorState } from '../empty_states'; import { EngineTable, IEngineTablePagination } from './engine_table'; import { EngineOverview } from './'; @@ -56,13 +56,6 @@ describe('EngineOverview', () => { }); expect(wrapper.find(ErrorState)).toHaveLength(1); }); - - it('hasNoAccount', async () => { - const wrapper = await mountWithApiMock({ - get: () => Promise.reject({ body: { message: 'no-as-account' } }), - }); - expect(wrapper.find(NoUserState)).toHaveLength(1); - }); }); describe('happy-path states', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx index 7f7c271d2e68bb..c4cebf30ab45e2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx @@ -24,7 +24,7 @@ import { KibanaContext, IKibanaContext } from '../../../index'; import EnginesIcon from '../../assets/engine.svg'; import MetaEnginesIcon from '../../assets/meta_engine.svg'; -import { LoadingState, EmptyState, NoUserState, ErrorState } from '../empty_states'; +import { LoadingState, EmptyState, ErrorState } from '../empty_states'; import { EngineOverviewHeader } from '../engine_overview_header'; import { EngineTable } from './engine_table'; @@ -35,7 +35,6 @@ export const EngineOverview: React.FC = () => { const { license } = useContext(LicenseContext) as ILicenseContext; const [isLoading, setIsLoading] = useState(true); - const [hasNoAccount, setHasNoAccount] = useState(false); const [hasErrorConnecting, setHasErrorConnecting] = useState(false); const [engines, setEngines] = useState([]); @@ -59,11 +58,7 @@ export const EngineOverview: React.FC = () => { setIsLoading(false); } catch (error) { - if (error?.body?.message === 'no-as-account') { - setHasNoAccount(true); - } else { - setHasErrorConnecting(true); - } + setHasErrorConnecting(true); } }; @@ -84,7 +79,6 @@ export const EngineOverview: React.FC = () => { }, [license, metaEnginesPage]); if (hasErrorConnecting) return ; - if (hasNoAccount) return ; if (isLoading) return ; if (!engines.length) return ; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/get_username/get_username.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/get_username/get_username.test.ts deleted file mode 100644 index c0a9ee5a90ea5e..00000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/shared/get_username/get_username.test.ts +++ /dev/null @@ -1,29 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { getUserName } from './get_username'; - -describe('getUserName', () => { - it('fetches the current username from the DOM', () => { - document.body.innerHTML = - '
' + - ' ' + - '
'; - - expect(getUserName()).toEqual('foo_bar_baz'); - }); - - it('returns null if the expected DOM does not exist', () => { - document.body.innerHTML = '
' + '' + '
'; - expect(getUserName()).toEqual(null); - - document.body.innerHTML = '
'; - expect(getUserName()).toEqual(null); - - document.body.innerHTML = '
'; - expect(getUserName()).toEqual(null); - }); -}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/get_username/get_username.ts b/x-pack/plugins/enterprise_search/public/applications/shared/get_username/get_username.ts deleted file mode 100644 index 3010da50f913e5..00000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/shared/get_username/get_username.ts +++ /dev/null @@ -1,20 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -/** - * Attempt to get the current Kibana user's username - * by querying the DOM - */ -export const getUserName: () => null | string = () => { - const userMenu = document.getElementById('headerUserMenu'); - if (!userMenu) return null; - - const avatar = userMenu.querySelector('.euiAvatar'); - if (!avatar) return null; - - const username = avatar.getAttribute('aria-label'); - return username; -}; diff --git a/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts index 9e82a7f8da9ee8..56722c85afbd06 100644 --- a/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts +++ b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts @@ -25,7 +25,6 @@ describe('App Search Telemetry Usage Collector', () => { 'ui_viewed.setup_guide': 10, 'ui_viewed.engines_overview': 20, 'ui_error.cannot_connect': 3, - 'ui_error.no_as_account': 4, 'ui_clicked.create_first_engine_button': 40, 'ui_clicked.header_launch_button': 50, 'ui_clicked.engine_table_link': 60, @@ -64,7 +63,6 @@ describe('App Search Telemetry Usage Collector', () => { }, ui_error: { cannot_connect: 3, - no_as_account: 4, }, ui_clicked: { create_first_engine_button: 40, @@ -87,7 +85,6 @@ describe('App Search Telemetry Usage Collector', () => { }, ui_error: { cannot_connect: 0, - no_as_account: 0, }, ui_clicked: { create_first_engine_button: 0, diff --git a/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.ts b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.ts index f9376f65f79a7f..91c88c82f56140 100644 --- a/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.ts +++ b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.ts @@ -19,7 +19,6 @@ interface ITelemetry { }; ui_error: { cannot_connect: number; - no_as_account: number; }; ui_clicked: { create_first_engine_button: number; @@ -49,7 +48,6 @@ export const registerTelemetryUsageCollector = ( }, ui_error: { cannot_connect: { type: 'long' }, - no_as_account: { type: 'long' }, }, ui_clicked: { create_first_engine_button: { type: 'long' }, @@ -78,7 +76,6 @@ const fetchTelemetryMetrics = async (savedObjects: SavedObjectsServiceStart) => }, ui_error: { cannot_connect: 0, - no_as_account: 0, }, ui_clicked: { create_first_engine_button: 0, diff --git a/x-pack/plugins/enterprise_search/server/index.ts b/x-pack/plugins/enterprise_search/server/index.ts index faf8f61bd2b9e2..88fc48e81701f4 100644 --- a/x-pack/plugins/enterprise_search/server/index.ts +++ b/x-pack/plugins/enterprise_search/server/index.ts @@ -14,6 +14,9 @@ export const plugin = (initializerContext: PluginInitializerContext) => { export const configSchema = schema.object({ host: schema.maybe(schema.string()), + enabled: schema.boolean({ defaultValue: true }), + accessCheckTimeout: schema.number({ defaultValue: 5000 }), + accessCheckTimeoutWarning: schema.number({ defaultValue: 300 }), }); type ConfigType = TypeOf; diff --git a/x-pack/plugins/enterprise_search/server/lib/check_access.test.ts b/x-pack/plugins/enterprise_search/server/lib/check_access.test.ts new file mode 100644 index 00000000000000..11d4a387b533ff --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/lib/check_access.test.ts @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +jest.mock('./enterprise_search_config_api', () => ({ + callEnterpriseSearchConfigAPI: jest.fn(), +})); +import { callEnterpriseSearchConfigAPI } from './enterprise_search_config_api'; + +import { checkAccess } from './check_access'; + +describe('checkAccess', () => { + const mockSecurity = { + authz: { + mode: { + useRbacForRequest: () => true, + }, + checkPrivilegesWithRequest: () => ({ + globally: () => ({ + hasAllRequested: false, + }), + }), + actions: { + ui: { + get: () => null, + }, + }, + }, + }; + const mockDependencies = { + request: {}, + config: { host: 'http://localhost:3002' }, + security: mockSecurity, + } as any; + + describe('when security is disabled', () => { + it('should allow all access', async () => { + const security = undefined; + expect(await checkAccess({ ...mockDependencies, security })).toEqual({ + hasAppSearchAccess: true, + hasWorkplaceSearchAccess: true, + }); + }); + }); + + describe('when the user is a superuser', () => { + it('should allow all access', async () => { + const security = { + ...mockSecurity, + authz: { + mode: { useRbacForRequest: () => true }, + checkPrivilegesWithRequest: () => ({ + globally: () => ({ + hasAllRequested: true, + }), + }), + actions: { ui: { get: () => {} } }, + }, + }; + expect(await checkAccess({ ...mockDependencies, security })).toEqual({ + hasAppSearchAccess: true, + hasWorkplaceSearchAccess: true, + }); + }); + + it('falls back to assuming a non-superuser role if auth credentials are missing', async () => { + const security = { + authz: { + ...mockSecurity.authz, + checkPrivilegesWithRequest: () => ({ + globally: () => Promise.reject({ statusCode: 403 }), + }), + }, + }; + expect(await checkAccess({ ...mockDependencies, security })).toEqual({ + hasAppSearchAccess: false, + hasWorkplaceSearchAccess: false, + }); + }); + + it('throws other authz errors', async () => { + const security = { + authz: { + ...mockSecurity.authz, + checkPrivilegesWithRequest: undefined, + }, + }; + await expect(checkAccess({ ...mockDependencies, security })).rejects.toThrow(); + }); + }); + + describe('when the user is a non-superuser', () => { + describe('when enterpriseSearch.host is not set in kibana.yml', () => { + it('should deny all access', async () => { + const config = { host: undefined }; + expect(await checkAccess({ ...mockDependencies, config })).toEqual({ + hasAppSearchAccess: false, + hasWorkplaceSearchAccess: false, + }); + }); + }); + + describe('when enterpriseSearch.host is set in kibana.yml', () => { + it('should make a http call and return the access response', async () => { + (callEnterpriseSearchConfigAPI as jest.Mock).mockImplementationOnce(() => ({ + access: { + hasAppSearchAccess: false, + hasWorkplaceSearchAccess: true, + }, + })); + expect(await checkAccess(mockDependencies)).toEqual({ + hasAppSearchAccess: false, + hasWorkplaceSearchAccess: true, + }); + }); + + it('falls back to no access if no http response', async () => { + (callEnterpriseSearchConfigAPI as jest.Mock).mockImplementationOnce(() => ({})); + expect(await checkAccess(mockDependencies)).toEqual({ + hasAppSearchAccess: false, + hasWorkplaceSearchAccess: false, + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/lib/check_access.ts b/x-pack/plugins/enterprise_search/server/lib/check_access.ts new file mode 100644 index 00000000000000..e5f996dcdfd718 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/lib/check_access.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { KibanaRequest, Logger } from 'src/core/server'; +import { SecurityPluginSetup } from '../../../security/server'; +import { ServerConfigType } from '../plugin'; + +import { callEnterpriseSearchConfigAPI } from './enterprise_search_config_api'; + +interface ICheckAccess { + request: KibanaRequest; + security?: SecurityPluginSetup; + config: ServerConfigType; + log: Logger; +} +export interface IAccess { + hasAppSearchAccess: boolean; + hasWorkplaceSearchAccess: boolean; +} + +const ALLOW_ALL_PLUGINS = { + hasAppSearchAccess: true, + hasWorkplaceSearchAccess: true, +}; +const DENY_ALL_PLUGINS = { + hasAppSearchAccess: false, + hasWorkplaceSearchAccess: false, +}; + +/** + * Determines whether the user has access to our Enterprise Search products + * via HTTP call. If not, we hide the corresponding plugin links from the + * nav and catalogue in `plugin.ts`, which disables plugin access + */ +export const checkAccess = async ({ + config, + security, + request, + log, +}: ICheckAccess): Promise => { + // If security has been disabled, always show the plugin + if (!security?.authz?.mode.useRbacForRequest(request)) { + return ALLOW_ALL_PLUGINS; + } + + // If the user is a "superuser" or has the base Kibana all privilege globally, always show the plugin + const isSuperUser = async (): Promise => { + try { + const { hasAllRequested } = await security.authz + .checkPrivilegesWithRequest(request) + .globally(security.authz.actions.ui.get('enterprise_search', 'app_search')); + return hasAllRequested; + } catch (err) { + if (err.statusCode === 401 || err.statusCode === 403) { + return false; + } + throw err; + } + }; + if (await isSuperUser()) { + return ALLOW_ALL_PLUGINS; + } + + // Hide the plugin when enterpriseSearch.host is not defined in kibana.yml + if (!config.host) { + return DENY_ALL_PLUGINS; + } + + // When enterpriseSearch.host is defined in kibana.yml, + // make a HTTP call which returns product access + const { access } = (await callEnterpriseSearchConfigAPI({ request, config, log })) || {}; + return access || DENY_ALL_PLUGINS; +}; diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts new file mode 100644 index 00000000000000..cf35a458b48258 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts @@ -0,0 +1,111 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +jest.mock('node-fetch'); +const fetchMock = require('node-fetch') as jest.Mock; +const { Response } = jest.requireActual('node-fetch'); + +import { loggingSystemMock } from 'src/core/server/mocks'; + +import { callEnterpriseSearchConfigAPI } from './enterprise_search_config_api'; + +describe('callEnterpriseSearchConfigAPI', () => { + const mockConfig = { + host: 'http://localhost:3002', + accessCheckTimeout: 200, + accessCheckTimeoutWarning: 100, + }; + const mockRequest = { + url: { path: '/app/kibana' }, + headers: { authorization: '==someAuth' }, + }; + const mockDependencies = { + config: mockConfig, + request: mockRequest, + log: loggingSystemMock.create().get(), + } as any; + + const mockResponse = { + version: { + number: '1.0.0', + }, + settings: { + external_url: 'http://some.vanity.url/', + }, + access: { + user: 'someuser', + products: { + app_search: true, + workplace_search: false, + }, + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('calls the config API endpoint', async () => { + fetchMock.mockImplementationOnce((url: string) => { + expect(url).toEqual('http://localhost:3002/api/ent/v1/internal/client_config'); + return Promise.resolve(new Response(JSON.stringify(mockResponse))); + }); + + expect(await callEnterpriseSearchConfigAPI(mockDependencies)).toEqual({ + publicUrl: 'http://some.vanity.url/', + access: { + hasAppSearchAccess: true, + hasWorkplaceSearchAccess: false, + }, + }); + }); + + it('returns early if config.host is not set', async () => { + const config = { host: '' }; + + expect(await callEnterpriseSearchConfigAPI({ ...mockDependencies, config })).toEqual({}); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('handles server errors', async () => { + fetchMock.mockImplementationOnce(() => { + return Promise.reject('500'); + }); + expect(await callEnterpriseSearchConfigAPI(mockDependencies)).toEqual({}); + expect(mockDependencies.log.error).toHaveBeenCalledWith( + 'Could not perform access check to Enterprise Search: 500' + ); + + fetchMock.mockImplementationOnce(() => { + return Promise.resolve('Bad Data'); + }); + expect(await callEnterpriseSearchConfigAPI(mockDependencies)).toEqual({}); + expect(mockDependencies.log.error).toHaveBeenCalledWith( + 'Could not perform access check to Enterprise Search: TypeError: response.json is not a function' + ); + }); + + it('handles timeouts', async () => { + jest.useFakeTimers(); + + // Warning + callEnterpriseSearchConfigAPI(mockDependencies); + jest.advanceTimersByTime(150); + expect(mockDependencies.log.warn).toHaveBeenCalledWith( + 'Enterprise Search access check took over 100ms. Please ensure your Enterprise Search server is respondingly normally and not adversely impacting Kibana load speeds.' + ); + + // Timeout + fetchMock.mockImplementationOnce(async () => { + jest.advanceTimersByTime(250); + return Promise.reject({ name: 'AbortError' }); + }); + expect(await callEnterpriseSearchConfigAPI(mockDependencies)).toEqual({}); + expect(mockDependencies.log.warn).toHaveBeenCalledWith( + "Exceeded 200ms timeout while checking http://localhost:3002. Please consider increasing your enterpriseSearch.accessCheckTimeout value so that users aren't prevented from accessing Enterprise Search plugins due to slow responses." + ); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts new file mode 100644 index 00000000000000..a8eb5a4ec36115 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import AbortController from 'abort-controller'; +import fetch from 'node-fetch'; + +import { KibanaRequest, Logger } from 'src/core/server'; +import { ServerConfigType } from '../plugin'; +import { IAccess } from './check_access'; + +interface IParams { + request: KibanaRequest; + config: ServerConfigType; + log: Logger; +} +interface IReturn { + publicUrl?: string; + access?: IAccess; +} + +/** + * Calls an internal Enterprise Search API endpoint which returns + * useful various settings (e.g. product access, external URL) + * needed by the Kibana plugin at the setup stage + */ +const ENDPOINT = '/api/ent/v1/internal/client_config'; + +export const callEnterpriseSearchConfigAPI = async ({ + config, + log, + request, +}: IParams): Promise => { + if (!config.host) return {}; + + const TIMEOUT_WARNING = `Enterprise Search access check took over ${config.accessCheckTimeoutWarning}ms. Please ensure your Enterprise Search server is respondingly normally and not adversely impacting Kibana load speeds.`; + const TIMEOUT_MESSAGE = `Exceeded ${config.accessCheckTimeout}ms timeout while checking ${config.host}. Please consider increasing your enterpriseSearch.accessCheckTimeout value so that users aren't prevented from accessing Enterprise Search plugins due to slow responses.`; + const CONNECTION_ERROR = 'Could not perform access check to Enterprise Search'; + + const warningTimeout = setTimeout(() => { + log.warn(TIMEOUT_WARNING); + }, config.accessCheckTimeoutWarning); + + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(); + }, config.accessCheckTimeout); + + try { + const enterpriseSearchUrl = encodeURI(`${config.host}${ENDPOINT}`); + const response = await fetch(enterpriseSearchUrl, { + headers: { Authorization: request.headers.authorization as string }, + signal: controller.signal, + }); + const data = await response.json(); + + return { + publicUrl: data?.settings?.external_url, + access: { + hasAppSearchAccess: !!data?.access?.products?.app_search, + hasWorkplaceSearchAccess: !!data?.access?.products?.workplace_search, + }, + }; + } catch (err) { + if (err.name === 'AbortError') { + log.warn(TIMEOUT_MESSAGE); + } else { + log.error(`${CONNECTION_ERROR}: ${err.toString()}`); + if (err instanceof Error) log.debug(err.stack as string); + } + return {}; + } finally { + clearTimeout(warningTimeout); + clearTimeout(timeout); + } +}; diff --git a/x-pack/plugins/enterprise_search/server/plugin.ts b/x-pack/plugins/enterprise_search/server/plugin.ts index a8430ad8f56af5..62c448bc837600 100644 --- a/x-pack/plugins/enterprise_search/server/plugin.ts +++ b/x-pack/plugins/enterprise_search/server/plugin.ts @@ -13,9 +13,14 @@ import { Logger, SavedObjectsServiceStart, IRouter, + KibanaRequest, } from 'src/core/server'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { UICapabilities } from 'ui/capabilities'; +import { SecurityPluginSetup } from '../../security/server'; +import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; +import { checkAccess } from './lib/check_access'; import { registerEnginesRoute } from './routes/app_search/engines'; import { registerTelemetryRoute } from './routes/app_search/telemetry'; import { registerTelemetryUsageCollector } from './collectors/app_search/telemetry'; @@ -23,10 +28,15 @@ import { appSearchTelemetryType } from './saved_objects/app_search/telemetry'; export interface PluginsSetup { usageCollection?: UsageCollectionSetup; + security?: SecurityPluginSetup; + features: FeaturesPluginSetup; } export interface ServerConfigType { host?: string; + enabled: boolean; + accessCheckTimeout: number; + accessCheckTimeoutWarning: number; } export interface IRouteDependencies { @@ -46,11 +56,61 @@ export class EnterpriseSearchPlugin implements Plugin { } public async setup( - { http, savedObjects, getStartServices }: CoreSetup, - { usageCollection }: PluginsSetup + { capabilities, http, savedObjects, getStartServices }: CoreSetup, + { usageCollection, security, features }: PluginsSetup ) { - const router = http.createRouter(); const config = await this.config.pipe(first()).toPromise(); + + /** + * Register space/feature control + */ + features.registerFeature({ + id: 'enterprise_search', + name: 'Enterprise Search', + order: 0, + icon: 'logoEnterpriseSearch', + app: ['enterprise_search', 'app_search', 'workplace_search'], + catalogue: ['enterprise_search', 'app_search', 'workplace_search'], + privileges: null, + }); + + /** + * Register user access to the Enterprise Search plugins + */ + capabilities.registerProvider(() => ({ + navLinks: { + app_search: true, + }, + catalogue: { + app_search: true, + }, + })); + + capabilities.registerSwitcher( + async (request: KibanaRequest, uiCapabilities: UICapabilities) => { + const dependencies = { config, security, request, log: this.logger }; + + const { hasAppSearchAccess } = await checkAccess(dependencies); + // TODO: hasWorkplaceSearchAccess + + return { + ...uiCapabilities, + navLinks: { + ...uiCapabilities.navLinks, + app_search: hasAppSearchAccess, + }, + catalogue: { + ...uiCapabilities.catalogue, + app_search: hasAppSearchAccess, + }, + }; + } + ); + + /** + * Register routes + */ + const router = http.createRouter(); const dependencies = { router, config, log: this.logger }; registerEnginesRoute(dependencies); diff --git a/x-pack/plugins/enterprise_search/server/routes/__mocks__/config.mock.ts b/x-pack/plugins/enterprise_search/server/routes/__mocks__/config.mock.ts new file mode 100644 index 00000000000000..c468b140c948dd --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/__mocks__/config.mock.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const mockConfig = { + enabled: true, + host: 'http://localhost:3002', + accessCheckTimeout: 5000, + accessCheckTimeoutWarning: 300, +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/get_username/index.ts b/x-pack/plugins/enterprise_search/server/routes/__mocks__/index.ts similarity index 73% rename from x-pack/plugins/enterprise_search/public/applications/shared/get_username/index.ts rename to x-pack/plugins/enterprise_search/server/routes/__mocks__/index.ts index efc58065784fbf..8545d65b6f78d9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/get_username/index.ts +++ b/x-pack/plugins/enterprise_search/server/routes/__mocks__/index.ts @@ -4,4 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -export { getUserName } from './get_username'; +export { MockRouter } from './router.mock'; +export { mockConfig } from './config.mock'; diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts index c45514ae537fe9..77289810049f5f 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts @@ -5,7 +5,7 @@ */ import { loggingSystemMock } from 'src/core/server/mocks'; -import { MockRouter } from '../__mocks__/router.mock'; +import { MockRouter, mockConfig } from '../__mocks__'; import { registerEnginesRoute } from './engines'; @@ -37,9 +37,7 @@ describe('engine routes', () => { registerEnginesRoute({ router: mockRouter.router, log: mockLogger, - config: { - host: 'http://localhost:3002', - }, + config: mockConfig, }); }); @@ -64,24 +62,6 @@ describe('engine routes', () => { }); }); - describe('when the underlying App Search API redirects to /login', () => { - beforeEach(() => { - AppSearchAPI.shouldBeCalledWith( - `http://localhost:3002/as/engines/collection?type=indexed&page%5Bcurrent%5D=1&page%5Bsize%5D=10`, - { headers: { Authorization: AUTH_HEADER } } - ).andReturnRedirect(); - }); - - it('should return 403 with a message', async () => { - await mockRouter.callRoute(mockRequest); - - expect(mockRouter.response.forbidden).toHaveBeenCalledWith({ - body: 'no-as-account', - }); - expect(mockLogger.info).toHaveBeenCalledWith('No corresponding App Search account found'); - }); - }); - describe('when the App Search URL is invalid', () => { beforeEach(() => { AppSearchAPI.shouldBeCalledWith( @@ -152,18 +132,6 @@ describe('engine routes', () => { const AppSearchAPI = { shouldBeCalledWith(expectedUrl: string, expectedParams: object) { return { - andReturnRedirect() { - fetchMock.mockImplementation((url: string, params: object) => { - expect(url).toEqual(expectedUrl); - expect(params).toEqual(expectedParams); - - return Promise.resolve( - new Response('{}', { - url: '/login', - }) - ); - }); - }, andReturn(response: object) { fetchMock.mockImplementation((url: string, params: object) => { expect(url).toEqual(expectedUrl); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts index ffc7a0228454ff..b86555ca54a166 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts @@ -38,12 +38,6 @@ export function registerEnginesRoute({ router, config, log }: IRouteDependencies headers: { Authorization: request.headers.authorization as string }, }); - if (enginesResponse.url.endsWith('/login')) { - log.info('No corresponding App Search account found'); - // Note: Can't use response.unauthorized, Kibana will auto-log out the user - return response.forbidden({ body: 'no-as-account' }); - } - const engines = await enginesResponse.json(); const hasValidData = Array.isArray(engines?.results) && typeof engines?.meta?.page?.total_results === 'number'; diff --git a/x-pack/plugins/security/server/authorization/privileges/privileges.ts b/x-pack/plugins/security/server/authorization/privileges/privileges.ts index 5a15290a7f1a29..5ef9009ffd90b3 100644 --- a/x-pack/plugins/security/server/authorization/privileges/privileges.ts +++ b/x-pack/plugins/security/server/authorization/privileges/privileges.ts @@ -101,6 +101,7 @@ export function privilegesFactory( actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), + actions.ui.get('enterprise_search', 'app_search'), ...allActions, ], read: [actions.login, actions.version, ...readActions], diff --git a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json index 7b5bd3fd578d5d..1ea16a2a9940c9 100644 --- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @@ -23,9 +23,6 @@ "properties": { "cannot_connect": { "type": "long" - }, - "no_as_account": { - "type": "long" } } },