diff --git a/x-pack/plugins/enterprise_search/kibana.json b/x-pack/plugins/enterprise_search/kibana.json
index 3121d6bd470b0..bfd36b1736d68 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 b76cc73a996b4..12bf003564103 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 d1b65a4729a87..e92bf214c4cc7 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 b86b3caceefca..0000000000000
--- 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 18cf3dade2056..4d2a2ea1df9aa 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 7f7c271d2e68b..c4cebf30ab45e 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 c0a9ee5a90ea5..0000000000000
--- 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 3010da50f913e..0000000000000
--- 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 9e82a7f8da9ee..56722c85afbd0 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 f9376f65f79a7..91c88c82f5614 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 faf8f61bd2b9e..88fc48e81701f 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 0000000000000..11d4a387b533f
--- /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 0000000000000..e5f996dcdfd71
--- /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 0000000000000..cf35a458b4825
--- /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 0000000000000..a8eb5a4ec3611
--- /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 a8430ad8f56af..62c448bc83760 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 0000000000000..c468b140c948d
--- /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 efc58065784fb..8545d65b6f78d 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 c45514ae537fe..77289810049f5 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 ffc7a0228454f..b86555ca54a16 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 5a15290a7f1a2..5ef9009ffd90b 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 7b5bd3fd578d5..1ea16a2a9940c 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"
}
}
},