From cdd826dd82e2e47d76138dd8a4c36ed8d6e66a3c Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Fri, 15 Jan 2021 10:18:58 +0100 Subject: [PATCH] Expose AnonymousAccess service through Security OSS plugin. (#87091) --- ...na-plugin-core-server.capabilitiesstart.md | 2 +- ...r.capabilitiesstart.resolvecapabilities.md | 3 +- .../core/server/kibana-plugin-core-server.md | 1 + ...-core-server.resolvecapabilitiesoptions.md | 20 + ...abilitiesoptions.usedefaultcapabilities.md | 13 + .../capabilities/capabilities_service.test.ts | 41 ++ .../capabilities/capabilities_service.ts | 20 +- src/core/server/capabilities/index.ts | 7 +- src/core/server/index.ts | 7 +- src/core/server/server.api.md | 7 +- src/plugins/security_oss/common/app_state.ts | 29 ++ src/plugins/security_oss/common/index.ts | 20 + .../app_state/app_state_service.mock.ts | 32 ++ .../app_state/app_state_service.test.ts | 72 ++++ .../public/app_state/app_state_service.ts | 47 +++ .../security_oss/public/app_state/index.ts | 20 + .../insecure_cluster_service.test.tsx | 152 ++++--- .../insecure_cluster_service.tsx | 33 +- src/plugins/security_oss/public/plugin.ts | 40 +- .../security_oss/server/plugin.test.ts | 1 + src/plugins/security_oss/server/plugin.ts | 52 ++- .../routes/anonymous_access_capabilities.ts | 43 ++ ...insecure_cluster_alert.ts => app_state.ts} | 39 +- .../security_oss/server/routes/index.ts | 3 +- .../anonymous_access_capabilities.test.ts | 86 ++++ ...luster_alert.test.ts => app_state.test.ts} | 97 ++++- src/plugins/security_oss/tsconfig.json | 2 +- .../anonymous_access_service.mock.ts | 17 + .../anonymous_access_service.test.ts | 255 +++++++++++ .../anonymous_access_service.ts | 178 ++++++++ .../security/server/anonymous_access/index.ts | 7 + .../security/server/authentication/index.ts | 1 + .../authentication/providers/anonymous.ts | 57 ++- x-pack/plugins/security/server/errors.test.ts | 72 +++- x-pack/plugins/security/server/errors.ts | 25 +- x-pack/plugins/security/server/plugin.test.ts | 119 +++--- x-pack/plugins/security/server/plugin.ts | 86 +++- .../tests/anonymous/capabilities.ts | 395 ++++++++++++++++++ .../tests/anonymous/index.ts | 1 + 39 files changed, 1862 insertions(+), 240 deletions(-) create mode 100644 docs/development/core/server/kibana-plugin-core-server.resolvecapabilitiesoptions.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.resolvecapabilitiesoptions.usedefaultcapabilities.md create mode 100644 src/plugins/security_oss/common/app_state.ts create mode 100644 src/plugins/security_oss/common/index.ts create mode 100644 src/plugins/security_oss/public/app_state/app_state_service.mock.ts create mode 100644 src/plugins/security_oss/public/app_state/app_state_service.test.ts create mode 100644 src/plugins/security_oss/public/app_state/app_state_service.ts create mode 100644 src/plugins/security_oss/public/app_state/index.ts create mode 100644 src/plugins/security_oss/server/routes/anonymous_access_capabilities.ts rename src/plugins/security_oss/server/routes/{display_insecure_cluster_alert.ts => app_state.ts} (57%) create mode 100644 src/plugins/security_oss/server/routes/integration_tests/anonymous_access_capabilities.test.ts rename src/plugins/security_oss/server/routes/integration_tests/{display_insecure_cluster_alert.test.ts => app_state.test.ts} (51%) create mode 100644 x-pack/plugins/security/server/anonymous_access/anonymous_access_service.mock.ts create mode 100644 x-pack/plugins/security/server/anonymous_access/anonymous_access_service.test.ts create mode 100644 x-pack/plugins/security/server/anonymous_access/anonymous_access_service.ts create mode 100644 x-pack/plugins/security/server/anonymous_access/index.ts create mode 100644 x-pack/test/security_api_integration/tests/anonymous/capabilities.ts diff --git a/docs/development/core/server/kibana-plugin-core-server.capabilitiesstart.md b/docs/development/core/server/kibana-plugin-core-server.capabilitiesstart.md index 1af0bea4067aa..217a782be9d8b 100644 --- a/docs/development/core/server/kibana-plugin-core-server.capabilitiesstart.md +++ b/docs/development/core/server/kibana-plugin-core-server.capabilitiesstart.md @@ -16,5 +16,5 @@ export interface CapabilitiesStart | Method | Description | | --- | --- | -| [resolveCapabilities(request)](./kibana-plugin-core-server.capabilitiesstart.resolvecapabilities.md) | Resolve the [Capabilities](./kibana-plugin-core-server.capabilities.md) to be used for given request | +| [resolveCapabilities(request, options)](./kibana-plugin-core-server.capabilitiesstart.resolvecapabilities.md) | Resolve the [Capabilities](./kibana-plugin-core-server.capabilities.md) to be used for given request | diff --git a/docs/development/core/server/kibana-plugin-core-server.capabilitiesstart.resolvecapabilities.md b/docs/development/core/server/kibana-plugin-core-server.capabilitiesstart.resolvecapabilities.md index 63736a38c2b28..d0e02499c580e 100644 --- a/docs/development/core/server/kibana-plugin-core-server.capabilitiesstart.resolvecapabilities.md +++ b/docs/development/core/server/kibana-plugin-core-server.capabilitiesstart.resolvecapabilities.md @@ -9,7 +9,7 @@ Resolve the [Capabilities](./kibana-plugin-core-server.capabilities.md) to be us Signature: ```typescript -resolveCapabilities(request: KibanaRequest): Promise; +resolveCapabilities(request: KibanaRequest, options?: ResolveCapabilitiesOptions): Promise; ``` ## Parameters @@ -17,6 +17,7 @@ resolveCapabilities(request: KibanaRequest): Promise; | Parameter | Type | Description | | --- | --- | --- | | request | KibanaRequest | | +| options | ResolveCapabilitiesOptions | | Returns: diff --git a/docs/development/core/server/kibana-plugin-core-server.md b/docs/development/core/server/kibana-plugin-core-server.md index 36da1b51ee7b0..06c7983f89a78 100644 --- a/docs/development/core/server/kibana-plugin-core-server.md +++ b/docs/development/core/server/kibana-plugin-core-server.md @@ -128,6 +128,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [PluginInitializerContext](./kibana-plugin-core-server.plugininitializercontext.md) | Context that's available to plugins during initialization stage. | | [PluginManifest](./kibana-plugin-core-server.pluginmanifest.md) | Describes the set of required and optional properties plugin can define in its mandatory JSON manifest file. | | [RequestHandlerContext](./kibana-plugin-core-server.requesthandlercontext.md) | Plugin specific context passed to a route handler.Provides the following clients and services: - [savedObjects.client](./kibana-plugin-core-server.savedobjectsclient.md) - Saved Objects client which uses the credentials of the incoming request - [savedObjects.typeRegistry](./kibana-plugin-core-server.isavedobjecttyperegistry.md) - Type registry containing all the registered types. - [elasticsearch.client](./kibana-plugin-core-server.iscopedclusterclient.md) - Elasticsearch data client which uses the credentials of the incoming request - [elasticsearch.legacy.client](./kibana-plugin-core-server.legacyscopedclusterclient.md) - The legacy Elasticsearch data client which uses the credentials of the incoming request - [uiSettings.client](./kibana-plugin-core-server.iuisettingsclient.md) - uiSettings client which uses the credentials of the incoming request | +| [ResolveCapabilitiesOptions](./kibana-plugin-core-server.resolvecapabilitiesoptions.md) | Defines a set of additional options for the resolveCapabilities method of [CapabilitiesStart](./kibana-plugin-core-server.capabilitiesstart.md). | | [RouteConfig](./kibana-plugin-core-server.routeconfig.md) | Route specific configuration. | | [RouteConfigOptions](./kibana-plugin-core-server.routeconfigoptions.md) | Additional route options. | | [RouteConfigOptionsBody](./kibana-plugin-core-server.routeconfigoptionsbody.md) | Additional body options for a route | diff --git a/docs/development/core/server/kibana-plugin-core-server.resolvecapabilitiesoptions.md b/docs/development/core/server/kibana-plugin-core-server.resolvecapabilitiesoptions.md new file mode 100644 index 0000000000000..f118c34c9be0f --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.resolvecapabilitiesoptions.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [ResolveCapabilitiesOptions](./kibana-plugin-core-server.resolvecapabilitiesoptions.md) + +## ResolveCapabilitiesOptions interface + +Defines a set of additional options for the `resolveCapabilities` method of [CapabilitiesStart](./kibana-plugin-core-server.capabilitiesstart.md). + +Signature: + +```typescript +export interface ResolveCapabilitiesOptions +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [useDefaultCapabilities](./kibana-plugin-core-server.resolvecapabilitiesoptions.usedefaultcapabilities.md) | boolean | Indicates if capability switchers are supposed to return a default set of capabilities. | + diff --git a/docs/development/core/server/kibana-plugin-core-server.resolvecapabilitiesoptions.usedefaultcapabilities.md b/docs/development/core/server/kibana-plugin-core-server.resolvecapabilitiesoptions.usedefaultcapabilities.md new file mode 100644 index 0000000000000..792893a3fc096 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.resolvecapabilitiesoptions.usedefaultcapabilities.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [ResolveCapabilitiesOptions](./kibana-plugin-core-server.resolvecapabilitiesoptions.md) > [useDefaultCapabilities](./kibana-plugin-core-server.resolvecapabilitiesoptions.usedefaultcapabilities.md) + +## ResolveCapabilitiesOptions.useDefaultCapabilities property + +Indicates if capability switchers are supposed to return a default set of capabilities. + +Signature: + +```typescript +useDefaultCapabilities: boolean; +``` diff --git a/src/core/server/capabilities/capabilities_service.test.ts b/src/core/server/capabilities/capabilities_service.test.ts index 42dc1604281b8..efa51066d4417 100644 --- a/src/core/server/capabilities/capabilities_service.test.ts +++ b/src/core/server/capabilities/capabilities_service.test.ts @@ -184,5 +184,46 @@ describe('CapabilitiesService', () => { } `); }); + + it('allows to indicate that default capabilities should be returned', async () => { + setup.registerProvider(() => ({ customSection: { isDefault: true } })); + setup.registerSwitcher((req, capabilities, useDefaultCapabilities) => + useDefaultCapabilities ? capabilities : { customSection: { isDefault: false } } + ); + + const start = service.start(); + expect(await start.resolveCapabilities({} as any)).toMatchInlineSnapshot(` + Object { + "catalogue": Object {}, + "customSection": Object { + "isDefault": false, + }, + "management": Object {}, + "navLinks": Object {}, + } + `); + expect(await start.resolveCapabilities({} as any, { useDefaultCapabilities: false })) + .toMatchInlineSnapshot(` + Object { + "catalogue": Object {}, + "customSection": Object { + "isDefault": false, + }, + "management": Object {}, + "navLinks": Object {}, + } + `); + expect(await start.resolveCapabilities({} as any, { useDefaultCapabilities: true })) + .toMatchInlineSnapshot(` + Object { + "catalogue": Object {}, + "customSection": Object { + "isDefault": true, + }, + "management": Object {}, + "navLinks": Object {}, + } + `); + }); }); }); diff --git a/src/core/server/capabilities/capabilities_service.ts b/src/core/server/capabilities/capabilities_service.ts index 9af945d17b2ad..f18848e04f547 100644 --- a/src/core/server/capabilities/capabilities_service.ts +++ b/src/core/server/capabilities/capabilities_service.ts @@ -104,6 +104,18 @@ export interface CapabilitiesSetup { registerSwitcher(switcher: CapabilitiesSwitcher): void; } +/** + * Defines a set of additional options for the `resolveCapabilities` method of {@link CapabilitiesStart}. + * + * @public + */ +export interface ResolveCapabilitiesOptions { + /** + * Indicates if capability switchers are supposed to return a default set of capabilities. + */ + useDefaultCapabilities: boolean; +} + /** * APIs to access the application {@link Capabilities}. * @@ -113,7 +125,10 @@ export interface CapabilitiesStart { /** * Resolve the {@link Capabilities} to be used for given request */ - resolveCapabilities(request: KibanaRequest): Promise; + resolveCapabilities( + request: KibanaRequest, + options?: ResolveCapabilitiesOptions + ): Promise; } interface SetupDeps { @@ -162,7 +177,8 @@ export class CapabilitiesService { public start(): CapabilitiesStart { return { - resolveCapabilities: (request) => this.resolveCapabilities(request, [], false), + resolveCapabilities: (request, options) => + this.resolveCapabilities(request, [], options?.useDefaultCapabilities ?? false), }; } } diff --git a/src/core/server/capabilities/index.ts b/src/core/server/capabilities/index.ts index ac9454f01391c..cd14ee949a5d2 100644 --- a/src/core/server/capabilities/index.ts +++ b/src/core/server/capabilities/index.ts @@ -17,5 +17,10 @@ * under the License. */ -export { CapabilitiesService, CapabilitiesSetup, CapabilitiesStart } from './capabilities_service'; +export { + CapabilitiesService, + CapabilitiesSetup, + CapabilitiesStart, + ResolveCapabilitiesOptions, +} from './capabilities_service'; export { Capabilities, CapabilitiesSwitcher, CapabilitiesProvider } from './types'; diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 0dae17b4c211e..72b224cb68df2 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -87,7 +87,12 @@ export { }; export { bootstrap } from './bootstrap'; -export { Capabilities, CapabilitiesProvider, CapabilitiesSwitcher } from './capabilities'; +export { + Capabilities, + CapabilitiesProvider, + CapabilitiesSwitcher, + ResolveCapabilitiesOptions, +} from './capabilities'; export { ConfigPath, ConfigService, diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 45e6fc284bc8f..75f1580ceba8e 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -307,7 +307,7 @@ export interface CapabilitiesSetup { // @public export interface CapabilitiesStart { - resolveCapabilities(request: KibanaRequest): Promise; + resolveCapabilities(request: KibanaRequest, options?: ResolveCapabilitiesOptions): Promise; } // @public @@ -1924,6 +1924,11 @@ export type RequestHandlerContextProvider(handler: RequestHandler) => RequestHandler; +// @public +export interface ResolveCapabilitiesOptions { + useDefaultCapabilities: boolean; +} + // @public export type ResponseError = string | Error | { message: string | Error; diff --git a/src/plugins/security_oss/common/app_state.ts b/src/plugins/security_oss/common/app_state.ts new file mode 100644 index 0000000000000..e11ca5bd6294e --- /dev/null +++ b/src/plugins/security_oss/common/app_state.ts @@ -0,0 +1,29 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Defines Security OSS application state. + */ +export interface AppState { + insecureClusterAlert: { displayAlert: boolean }; + anonymousAccess: { + isEnabled: boolean; + accessURLParameters: Record | null; + }; +} diff --git a/src/plugins/security_oss/common/index.ts b/src/plugins/security_oss/common/index.ts new file mode 100644 index 0000000000000..f20d7dfd5e062 --- /dev/null +++ b/src/plugins/security_oss/common/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export type { AppState } from './app_state'; diff --git a/src/plugins/security_oss/public/app_state/app_state_service.mock.ts b/src/plugins/security_oss/public/app_state/app_state_service.mock.ts new file mode 100644 index 0000000000000..6eb628dd04b7e --- /dev/null +++ b/src/plugins/security_oss/public/app_state/app_state_service.mock.ts @@ -0,0 +1,32 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { AppState } from '../../common'; +import type { AppStateServiceStart } from './app_state_service'; + +export const mockAppStateService = { + createStart: (): jest.Mocked => { + return { getState: jest.fn() }; + }, + createAppState: (appState: Partial = {}) => ({ + insecureClusterAlert: { displayAlert: false }, + anonymousAccess: { isEnabled: false, accessURLParameters: null }, + ...appState, + }), +}; diff --git a/src/plugins/security_oss/public/app_state/app_state_service.test.ts b/src/plugins/security_oss/public/app_state/app_state_service.test.ts new file mode 100644 index 0000000000000..c498405b90f8b --- /dev/null +++ b/src/plugins/security_oss/public/app_state/app_state_service.test.ts @@ -0,0 +1,72 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { coreMock } from '../../../../core/public/mocks'; +import { AppStateService } from './app_state_service'; + +describe('AppStateService', () => { + describe('#start', () => { + it('returns default state for the anonymous routes', async () => { + const coreStart = coreMock.createStart(); + coreStart.http.anonymousPaths.isAnonymous.mockReturnValue(true); + + const appStateService = new AppStateService(); + await expect(appStateService.start({ core: coreStart }).getState()).resolves.toEqual({ + insecureClusterAlert: { displayAlert: false }, + anonymousAccess: { isEnabled: false, accessURLParameters: null }, + }); + + expect(coreStart.http.get).not.toHaveBeenCalled(); + }); + + it('returns default state if current state cannot be retrieved', async () => { + const coreStart = coreMock.createStart(); + coreStart.http.anonymousPaths.isAnonymous.mockReturnValue(false); + + const failureReason = new Error('Uh oh.'); + coreStart.http.get.mockRejectedValue(failureReason); + + const appStateService = new AppStateService(); + await expect(appStateService.start({ core: coreStart }).getState()).resolves.toEqual({ + insecureClusterAlert: { displayAlert: false }, + anonymousAccess: { isEnabled: false, accessURLParameters: null }, + }); + + expect(coreStart.http.get).toHaveBeenCalledTimes(1); + expect(coreStart.http.get).toHaveBeenCalledWith('/internal/security_oss/app_state'); + }); + + it('returns retrieved state', async () => { + const coreStart = coreMock.createStart(); + coreStart.http.anonymousPaths.isAnonymous.mockReturnValue(false); + + const state = { + insecureClusterAlert: { displayAlert: true }, + anonymousAccess: { isEnabled: true, accessURLParameters: { hint: 'some-hint' } }, + }; + coreStart.http.get.mockResolvedValue(state); + + const appStateService = new AppStateService(); + await expect(appStateService.start({ core: coreStart }).getState()).resolves.toEqual(state); + + expect(coreStart.http.get).toHaveBeenCalledTimes(1); + expect(coreStart.http.get).toHaveBeenCalledWith('/internal/security_oss/app_state'); + }); + }); +}); diff --git a/src/plugins/security_oss/public/app_state/app_state_service.ts b/src/plugins/security_oss/public/app_state/app_state_service.ts new file mode 100644 index 0000000000000..603073a66b0a0 --- /dev/null +++ b/src/plugins/security_oss/public/app_state/app_state_service.ts @@ -0,0 +1,47 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { CoreStart } from 'kibana/public'; +import { AppState } from '../../common'; + +const DEFAULT_APP_STATE = Object.freeze({ + insecureClusterAlert: { displayAlert: false }, + anonymousAccess: { isEnabled: false, accessURLParameters: null }, +}); + +interface StartDeps { + core: Pick; +} + +export interface AppStateServiceStart { + getState: () => Promise; +} + +/** + * Service that allows to retrieve application state. + */ +export class AppStateService { + start({ core }: StartDeps): AppStateServiceStart { + const appStatePromise = core.http.anonymousPaths.isAnonymous(window.location.pathname) + ? Promise.resolve(DEFAULT_APP_STATE) + : core.http.get('/internal/security_oss/app_state').catch(() => DEFAULT_APP_STATE); + + return { getState: () => appStatePromise }; + } +} diff --git a/src/plugins/security_oss/public/app_state/index.ts b/src/plugins/security_oss/public/app_state/index.ts new file mode 100644 index 0000000000000..d2f15560ec634 --- /dev/null +++ b/src/plugins/security_oss/public/app_state/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { AppStateService, AppStateServiceStart } from './app_state_service'; diff --git a/src/plugins/security_oss/public/insecure_cluster_service/insecure_cluster_service.test.tsx b/src/plugins/security_oss/public/insecure_cluster_service/insecure_cluster_service.test.tsx index 7bd2d9c4e5a0a..b044e2c36ec5e 100644 --- a/src/plugins/security_oss/public/insecure_cluster_service/insecure_cluster_service.test.tsx +++ b/src/plugins/security_oss/public/insecure_cluster_service/insecure_cluster_service.test.tsx @@ -17,10 +17,11 @@ * under the License. */ -import { InsecureClusterService } from './insecure_cluster_service'; -import { ConfigType } from '../config'; -import { coreMock } from '../../../../core/public/mocks'; import { nextTick } from '@kbn/test/jest'; +import { coreMock } from '../../../../core/public/mocks'; +import { mockAppStateService } from '../app_state/app_state_service.mock'; +import type { ConfigType } from '../config'; +import { InsecureClusterService } from './insecure_cluster_service'; let mockOnDismissCallback: (persist: boolean) => void = jest.fn().mockImplementation(() => { throw new Error('expected callback to be replaced!'); @@ -37,28 +38,14 @@ jest.mock('./components', () => { }); interface InitOpts { - displayAlert?: boolean; - isAnonymousPath?: boolean; tenant?: string; } -function initCore({ - displayAlert = true, - isAnonymousPath = false, - tenant = '/server-base-path', -}: InitOpts = {}) { +function initCore({ tenant = '/server-base-path' }: InitOpts = {}) { const coreSetup = coreMock.createSetup(); (coreSetup.http.basePath.serverBasePath as string) = tenant; const coreStart = coreMock.createStart(); - coreStart.http.get.mockImplementation(async (url: unknown) => { - if (url === '/internal/security_oss/display_insecure_cluster_alert') { - return { displayAlert }; - } - throw new Error(`unexpected call to http.get: ${url}`); - }); - coreStart.http.anonymousPaths.isAnonymous.mockReturnValue(isAnonymousPath); - coreStart.notifications.toasts.addWarning.mockReturnValue({ id: 'mock_alert_id' }); return { coreSetup, coreStart }; } @@ -67,52 +54,44 @@ describe('InsecureClusterService', () => { describe('display scenarios', () => { it('does not display an alert when the warning is explicitly disabled via config', async () => { const config: ConfigType = { showInsecureClusterWarning: false }; - const { coreSetup, coreStart } = initCore({ displayAlert: true }); + const { coreSetup, coreStart } = initCore(); const storage = coreMock.createStorage(); - const service = new InsecureClusterService(config, storage); - service.setup({ core: coreSetup }); - service.start({ core: coreStart }); - - await nextTick(); - - expect(coreStart.http.get).not.toHaveBeenCalled(); - expect(coreStart.notifications.toasts.addWarning).not.toHaveBeenCalled(); - - expect(coreStart.notifications.toasts.remove).not.toHaveBeenCalled(); - expect(storage.setItem).not.toHaveBeenCalled(); - }); - - it('does not display an alert when the endpoint check returns false', async () => { - const config: ConfigType = { showInsecureClusterWarning: true }; - const { coreSetup, coreStart } = initCore({ displayAlert: false }); - const storage = coreMock.createStorage(); + const appState = mockAppStateService.createStart(); + appState.getState.mockResolvedValue( + mockAppStateService.createAppState({ insecureClusterAlert: { displayAlert: true } }) + ); const service = new InsecureClusterService(config, storage); service.setup({ core: coreSetup }); - service.start({ core: coreStart }); + service.start({ core: coreStart, appState }); await nextTick(); - expect(coreStart.http.get).toHaveBeenCalledTimes(1); + expect(appState.getState).not.toHaveBeenCalled(); expect(coreStart.notifications.toasts.addWarning).not.toHaveBeenCalled(); expect(coreStart.notifications.toasts.remove).not.toHaveBeenCalled(); expect(storage.setItem).not.toHaveBeenCalled(); }); - it('does not display an alert when on an anonymous path', async () => { + it('does not display an alert when state indicates that alert should not be shown', async () => { const config: ConfigType = { showInsecureClusterWarning: true }; - const { coreSetup, coreStart } = initCore({ displayAlert: true, isAnonymousPath: true }); + const { coreSetup, coreStart } = initCore(); const storage = coreMock.createStorage(); + const appState = mockAppStateService.createStart(); + appState.getState.mockResolvedValue( + mockAppStateService.createAppState({ insecureClusterAlert: { displayAlert: false } }) + ); + const service = new InsecureClusterService(config, storage); service.setup({ core: coreSetup }); - service.start({ core: coreStart }); + service.start({ core: coreStart, appState }); await nextTick(); - expect(coreStart.http.get).not.toHaveBeenCalled(); + expect(appState.getState).toHaveBeenCalledTimes(1); expect(coreStart.notifications.toasts.addWarning).not.toHaveBeenCalled(); expect(coreStart.notifications.toasts.remove).not.toHaveBeenCalled(); @@ -121,17 +100,19 @@ describe('InsecureClusterService', () => { it('only reads storage information from the current tenant', async () => { const config: ConfigType = { showInsecureClusterWarning: true }; - const { coreSetup, coreStart } = initCore({ - displayAlert: true, - tenant: '/my-specific-tenant', - }); + const { coreSetup, coreStart } = initCore({ tenant: '/my-specific-tenant' }); const storage = coreMock.createStorage(); storage.getItem.mockReturnValue(JSON.stringify({ show: false })); + const appState = mockAppStateService.createStart(); + appState.getState.mockResolvedValue( + mockAppStateService.createAppState({ insecureClusterAlert: { displayAlert: true } }) + ); + const service = new InsecureClusterService(config, storage); service.setup({ core: coreSetup }); - service.start({ core: coreStart }); + service.start({ core: coreStart, appState }); await nextTick(); @@ -143,18 +124,23 @@ describe('InsecureClusterService', () => { it('does not display an alert when hidden via storage', async () => { const config: ConfigType = { showInsecureClusterWarning: true }; - const { coreSetup, coreStart } = initCore({ displayAlert: true }); + const { coreSetup, coreStart } = initCore(); const storage = coreMock.createStorage(); storage.getItem.mockReturnValue(JSON.stringify({ show: false })); + const appState = mockAppStateService.createStart(); + appState.getState.mockResolvedValue( + mockAppStateService.createAppState({ insecureClusterAlert: { displayAlert: true } }) + ); + const service = new InsecureClusterService(config, storage); service.setup({ core: coreSetup }); - service.start({ core: coreStart }); + service.start({ core: coreStart, appState }); await nextTick(); - expect(coreStart.http.get).not.toHaveBeenCalled(); + expect(appState.getState).not.toHaveBeenCalled(); expect(coreStart.notifications.toasts.addWarning).not.toHaveBeenCalled(); expect(coreStart.notifications.toasts.remove).not.toHaveBeenCalled(); @@ -163,18 +149,23 @@ describe('InsecureClusterService', () => { it('displays an alert when persisted preference is corrupted', async () => { const config: ConfigType = { showInsecureClusterWarning: true }; - const { coreSetup, coreStart } = initCore({ displayAlert: true }); + const { coreSetup, coreStart } = initCore(); const storage = coreMock.createStorage(); storage.getItem.mockReturnValue('{ this is a string of invalid JSON'); + const appState = mockAppStateService.createStart(); + appState.getState.mockResolvedValue( + mockAppStateService.createAppState({ insecureClusterAlert: { displayAlert: true } }) + ); + const service = new InsecureClusterService(config, storage); service.setup({ core: coreSetup }); - service.start({ core: coreStart }); + service.start({ core: coreStart, appState }); await nextTick(); - expect(coreStart.http.get).toHaveBeenCalledTimes(1); + expect(appState.getState).toHaveBeenCalledTimes(1); expect(coreStart.notifications.toasts.addWarning).toHaveBeenCalledTimes(1); expect(coreStart.notifications.toasts.remove).not.toHaveBeenCalled(); @@ -183,16 +174,21 @@ describe('InsecureClusterService', () => { it('displays an alert when enabled via config and endpoint checks', async () => { const config: ConfigType = { showInsecureClusterWarning: true }; - const { coreSetup, coreStart } = initCore({ displayAlert: true }); + const { coreSetup, coreStart } = initCore(); const storage = coreMock.createStorage(); + const appState = mockAppStateService.createStart(); + appState.getState.mockResolvedValue( + mockAppStateService.createAppState({ insecureClusterAlert: { displayAlert: true } }) + ); + const service = new InsecureClusterService(config, storage); service.setup({ core: coreSetup }); - service.start({ core: coreStart }); + service.start({ core: coreStart, appState }); await nextTick(); - expect(coreStart.http.get).toHaveBeenCalledTimes(1); + expect(appState.getState).toHaveBeenCalledTimes(1); expect(coreStart.notifications.toasts.addWarning).toHaveBeenCalledTimes(1); expect(coreStart.notifications.toasts.addWarning.mock.calls[0]).toMatchInlineSnapshot(` Array [ @@ -213,16 +209,21 @@ describe('InsecureClusterService', () => { it('dismisses the alert when requested, and remembers this preference', async () => { const config: ConfigType = { showInsecureClusterWarning: true }; - const { coreSetup, coreStart } = initCore({ displayAlert: true }); + const { coreSetup, coreStart } = initCore(); const storage = coreMock.createStorage(); + const appState = mockAppStateService.createStart(); + appState.getState.mockResolvedValue( + mockAppStateService.createAppState({ insecureClusterAlert: { displayAlert: true } }) + ); + const service = new InsecureClusterService(config, storage); service.setup({ core: coreSetup }); - service.start({ core: coreStart }); + service.start({ core: coreStart, appState }); await nextTick(); - expect(coreStart.http.get).toHaveBeenCalledTimes(1); + expect(appState.getState).toHaveBeenCalledTimes(1); expect(coreStart.notifications.toasts.addWarning).toHaveBeenCalledTimes(1); mockOnDismissCallback(true); @@ -257,19 +258,24 @@ describe('InsecureClusterService', () => { it('allows the alert title and text to be replaced', async () => { const config: ConfigType = { showInsecureClusterWarning: true }; - const { coreSetup, coreStart } = initCore({ displayAlert: true }); + const { coreSetup, coreStart } = initCore(); const storage = coreMock.createStorage(); + const appState = mockAppStateService.createStart(); + appState.getState.mockResolvedValue( + mockAppStateService.createAppState({ insecureClusterAlert: { displayAlert: true } }) + ); + const service = new InsecureClusterService(config, storage); const { setAlertTitle, setAlertText } = service.setup({ core: coreSetup }); setAlertTitle('some new title'); setAlertText('some new alert text'); - service.start({ core: coreStart }); + service.start({ core: coreStart, appState }); await nextTick(); - expect(coreStart.http.get).toHaveBeenCalledTimes(1); + expect(appState.getState).toHaveBeenCalledTimes(1); expect(coreStart.notifications.toasts.addWarning).toHaveBeenCalledTimes(1); expect(coreStart.notifications.toasts.addWarning.mock.calls[0]).toMatchInlineSnapshot(` Array [ @@ -292,16 +298,21 @@ describe('InsecureClusterService', () => { describe('#start', () => { it('allows the alert to be hidden via start contract, and remembers this preference', async () => { const config: ConfigType = { showInsecureClusterWarning: true }; - const { coreSetup, coreStart } = initCore({ displayAlert: true }); + const { coreSetup, coreStart } = initCore(); const storage = coreMock.createStorage(); + const appState = mockAppStateService.createStart(); + appState.getState.mockResolvedValue( + mockAppStateService.createAppState({ insecureClusterAlert: { displayAlert: true } }) + ); + const service = new InsecureClusterService(config, storage); service.setup({ core: coreSetup }); - const { hideAlert } = service.start({ core: coreStart }); + const { hideAlert } = service.start({ core: coreStart, appState }); await nextTick(); - expect(coreStart.http.get).toHaveBeenCalledTimes(1); + expect(appState.getState).toHaveBeenCalledTimes(1); expect(coreStart.notifications.toasts.addWarning).toHaveBeenCalledTimes(1); hideAlert(true); @@ -315,16 +326,21 @@ describe('InsecureClusterService', () => { it('allows the alert to be hidden via start contract, and does not remember the preference', async () => { const config: ConfigType = { showInsecureClusterWarning: true }; - const { coreSetup, coreStart } = initCore({ displayAlert: true }); + const { coreSetup, coreStart } = initCore(); const storage = coreMock.createStorage(); + const appState = mockAppStateService.createStart(); + appState.getState.mockResolvedValue( + mockAppStateService.createAppState({ insecureClusterAlert: { displayAlert: true } }) + ); + const service = new InsecureClusterService(config, storage); service.setup({ core: coreSetup }); - const { hideAlert } = service.start({ core: coreStart }); + const { hideAlert } = service.start({ core: coreStart, appState }); await nextTick(); - expect(coreStart.http.get).toHaveBeenCalledTimes(1); + expect(appState.getState).toHaveBeenCalledTimes(1); expect(coreStart.notifications.toasts.addWarning).toHaveBeenCalledTimes(1); hideAlert(false); diff --git a/src/plugins/security_oss/public/insecure_cluster_service/insecure_cluster_service.tsx b/src/plugins/security_oss/public/insecure_cluster_service/insecure_cluster_service.tsx index e6255233354b7..642ecf1dcf6e3 100644 --- a/src/plugins/security_oss/public/insecure_cluster_service/insecure_cluster_service.tsx +++ b/src/plugins/security_oss/public/insecure_cluster_service/insecure_cluster_service.tsx @@ -21,7 +21,8 @@ import { CoreSetup, CoreStart, MountPoint, Toast } from 'kibana/public'; import { BehaviorSubject, combineLatest, from } from 'rxjs'; import { distinctUntilChanged, map } from 'rxjs/operators'; -import { ConfigType } from '../config'; +import type { ConfigType } from '../config'; +import type { AppStateServiceStart } from '../app_state'; import { defaultAlertText, defaultAlertTitle } from './components'; interface SetupDeps { @@ -29,7 +30,8 @@ interface SetupDeps { } interface StartDeps { - core: Pick; + core: Pick; + appState: AppStateServiceStart; } export interface InsecureClusterServiceSetup { @@ -84,12 +86,9 @@ export class InsecureClusterService { }; } - public start({ core }: StartDeps): InsecureClusterServiceStart { - const shouldInitialize = - this.enabled && !core.http.anonymousPaths.isAnonymous(window.location.pathname); - - if (shouldInitialize) { - this.initializeAlert(core); + public start({ core, appState }: StartDeps): InsecureClusterServiceStart { + if (this.enabled) { + this.initializeAlert(core, appState); } return { @@ -97,24 +96,20 @@ export class InsecureClusterService { }; } - private initializeAlert(core: StartDeps['core']) { - const displayAlert$ = from( - core.http - .get<{ displayAlert: boolean }>('/internal/security_oss/display_insecure_cluster_alert') - .catch((e) => { - // in the event we can't make this call, assume we shouldn't display this alert. - return { displayAlert: false }; - }) - ); + private initializeAlert(core: StartDeps['core'], appState: AppStateServiceStart) { + const appState$ = from(appState.getState()); // 10 days is reasonably long enough to call "forever" for a page load. // Can't go too much longer than this. See https://github.com/elastic/kibana/issues/64264#issuecomment-618400354 const oneMinute = 60000; const tenDays = oneMinute * 60 * 24 * 10; - combineLatest([displayAlert$, this.alertVisibility$]) + combineLatest([appState$, this.alertVisibility$]) .pipe( - map(([{ displayAlert }, isAlertVisible]) => displayAlert && isAlertVisible), + map( + ([{ insecureClusterAlert }, isAlertVisible]) => + insecureClusterAlert.displayAlert && isAlertVisible + ), distinctUntilChanged() ) .subscribe((showAlert) => { diff --git a/src/plugins/security_oss/public/plugin.ts b/src/plugins/security_oss/public/plugin.ts index 2f3eed0bde5eb..756e20f34cfde 100644 --- a/src/plugins/security_oss/public/plugin.ts +++ b/src/plugins/security_oss/public/plugin.ts @@ -17,13 +17,20 @@ * under the License. */ -import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'src/core/public'; -import { ConfigType } from './config'; +import type { + Capabilities, + CoreSetup, + CoreStart, + Plugin, + PluginInitializerContext, +} from 'src/core/public'; +import type { ConfigType } from './config'; import { InsecureClusterService, InsecureClusterServiceSetup, InsecureClusterServiceStart, } from './insecure_cluster_service'; +import { AppStateService } from './app_state'; export interface SecurityOssPluginSetup { insecureCluster: InsecureClusterServiceSetup; @@ -31,18 +38,19 @@ export interface SecurityOssPluginSetup { export interface SecurityOssPluginStart { insecureCluster: InsecureClusterServiceStart; + anonymousAccess: { + getAccessURLParameters: () => Promise | null>; + getCapabilities: () => Promise; + }; } export class SecurityOssPlugin implements Plugin { - private readonly config: ConfigType; + private readonly config = this.initializerContext.config.get(); + private readonly insecureClusterService = new InsecureClusterService(this.config, localStorage); + private readonly appStateService = new AppStateService(); - private insecureClusterService: InsecureClusterService; - - constructor(private readonly initializerContext: PluginInitializerContext) { - this.config = this.initializerContext.config.get(); - this.insecureClusterService = new InsecureClusterService(this.config, localStorage); - } + constructor(private readonly initializerContext: PluginInitializerContext) {} public setup(core: CoreSetup) { return { @@ -51,8 +59,20 @@ export class SecurityOssPlugin } public start(core: CoreStart) { + const appState = this.appStateService.start({ core }); return { - insecureCluster: this.insecureClusterService.start({ core }), + insecureCluster: this.insecureClusterService.start({ core, appState }), + anonymousAccess: { + async getAccessURLParameters() { + const { anonymousAccess } = await appState.getState(); + return anonymousAccess.accessURLParameters; + }, + getCapabilities() { + return core.http.get( + '/internal/security_oss/anonymous_access/capabilities' + ); + }, + }, }; } } diff --git a/src/plugins/security_oss/server/plugin.test.ts b/src/plugins/security_oss/server/plugin.test.ts index 417da0c7e73bb..25a8fdc66c96b 100644 --- a/src/plugins/security_oss/server/plugin.test.ts +++ b/src/plugins/security_oss/server/plugin.test.ts @@ -30,6 +30,7 @@ describe('SecurityOss Plugin', () => { expect(Object.keys(contract)).toMatchInlineSnapshot(` Array [ "showInsecureClusterWarning$", + "setAnonymousAccessServiceProvider", ] `); }); diff --git a/src/plugins/security_oss/server/plugin.ts b/src/plugins/security_oss/server/plugin.ts index e48827f21a13a..43dd3eb758903 100644 --- a/src/plugins/security_oss/server/plugin.ts +++ b/src/plugins/security_oss/server/plugin.ts @@ -17,22 +17,55 @@ * under the License. */ -import { CoreSetup, Logger, Plugin, PluginInitializerContext } from 'kibana/server'; +import type { + Capabilities, + CoreSetup, + KibanaRequest, + Logger, + Plugin, + PluginInitializerContext, +} from 'kibana/server'; import { BehaviorSubject, Observable } from 'rxjs'; import { createClusterDataCheck } from './check_cluster_data'; import { ConfigType } from './config'; -import { setupDisplayInsecureClusterAlertRoute } from './routes'; +import { setupAppStateRoute, setupAnonymousAccessCapabilitiesRoute } from './routes'; export interface SecurityOssPluginSetup { /** * Allows consumers to show/hide the insecure cluster warning. */ showInsecureClusterWarning$: BehaviorSubject; + + /** + * Set the provider function that returns a service to deal with the anonymous access. + * @param provider + */ + setAnonymousAccessServiceProvider: (provider: () => AnonymousAccessService) => void; +} + +export interface AnonymousAccessService { + /** + * Indicates whether anonymous access is enabled. + */ + readonly isAnonymousAccessEnabled: boolean; + + /** + * A map of query string parameters that should be specified in the URL pointing to Kibana so + * that anonymous user can automatically log in. + */ + readonly accessURLParameters: Readonly> | null; + + /** + * Gets capabilities of the anonymous service account. + * @param request Kibana request instance. + */ + getCapabilities: (request: KibanaRequest) => Promise; } export class SecurityOssPlugin implements Plugin { private readonly config$: Observable; private readonly logger: Logger; + private anonymousAccessServiceProvider?: () => AnonymousAccessService; constructor(initializerContext: PluginInitializerContext) { this.config$ = initializerContext.config.create(); @@ -43,16 +76,29 @@ export class SecurityOssPlugin implements Plugin(true); - setupDisplayInsecureClusterAlertRoute({ + setupAppStateRoute({ router, log: this.logger, config$: this.config$, displayModifier$: showInsecureClusterWarning$, doesClusterHaveUserData: createClusterDataCheck(), + getAnonymousAccessService: () => this.anonymousAccessServiceProvider?.() ?? null, + }); + + setupAnonymousAccessCapabilitiesRoute({ + router, + getAnonymousAccessService: () => this.anonymousAccessServiceProvider?.() ?? null, }); return { showInsecureClusterWarning$, + setAnonymousAccessServiceProvider: (provider: () => AnonymousAccessService) => { + if (this.anonymousAccessServiceProvider) { + throw new Error('Anonymous Access service provider is already set.'); + } + + this.anonymousAccessServiceProvider = provider; + }, }; } diff --git a/src/plugins/security_oss/server/routes/anonymous_access_capabilities.ts b/src/plugins/security_oss/server/routes/anonymous_access_capabilities.ts new file mode 100644 index 0000000000000..afa0aa340d94d --- /dev/null +++ b/src/plugins/security_oss/server/routes/anonymous_access_capabilities.ts @@ -0,0 +1,43 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { IRouter } from 'kibana/server'; +import type { AnonymousAccessService } from '../plugin'; + +interface Deps { + router: IRouter; + getAnonymousAccessService: () => AnonymousAccessService | null; +} + +/** + * Defines route that returns capabilities of the anonymous service account. + */ +export function setupAnonymousAccessCapabilitiesRoute({ router, getAnonymousAccessService }: Deps) { + router.get( + { path: '/internal/security_oss/anonymous_access/capabilities', validate: false }, + async (_context, request, response) => { + const anonymousAccessService = getAnonymousAccessService(); + if (!anonymousAccessService) { + return response.custom({ statusCode: 501, body: 'Not Implemented' }); + } + + return response.ok({ body: await anonymousAccessService.getCapabilities(request) }); + } + ); +} diff --git a/src/plugins/security_oss/server/routes/display_insecure_cluster_alert.ts b/src/plugins/security_oss/server/routes/app_state.ts similarity index 57% rename from src/plugins/security_oss/server/routes/display_insecure_cluster_alert.ts rename to src/plugins/security_oss/server/routes/app_state.ts index 0f0f72f054b4c..a20f1938d7c92 100644 --- a/src/plugins/security_oss/server/routes/display_insecure_cluster_alert.ts +++ b/src/plugins/security_oss/server/routes/app_state.ts @@ -17,10 +17,12 @@ * under the License. */ -import { IRouter, Logger } from 'kibana/server'; +import type { IRouter, Logger } from 'kibana/server'; import { combineLatest, Observable } from 'rxjs'; +import type { AppState } from '../../common'; import { createClusterDataCheck } from '../check_cluster_data'; -import { ConfigType } from '../config'; +import type { ConfigType } from '../config'; +import type { AnonymousAccessService } from '../plugin'; interface Deps { router: IRouter; @@ -28,14 +30,16 @@ interface Deps { config$: Observable; displayModifier$: Observable; doesClusterHaveUserData: ReturnType; + getAnonymousAccessService: () => AnonymousAccessService | null; } -export const setupDisplayInsecureClusterAlertRoute = ({ +export const setupAppStateRoute = ({ router, log, config$, displayModifier$, doesClusterHaveUserData, + getAnonymousAccessService, }: Deps) => { let showInsecureClusterWarning = false; @@ -44,20 +48,27 @@ export const setupDisplayInsecureClusterAlertRoute = ({ }); router.get( - { - path: '/internal/security_oss/display_insecure_cluster_alert', - validate: false, - }, + { path: '/internal/security_oss/app_state', validate: false }, async (context, request, response) => { - if (!showInsecureClusterWarning) { - return response.ok({ body: { displayAlert: false } }); + let displayAlert = false; + if (showInsecureClusterWarning) { + displayAlert = await doesClusterHaveUserData( + context.core.elasticsearch.client.asInternalUser, + log + ); } - const hasData = await doesClusterHaveUserData( - context.core.elasticsearch.client.asInternalUser, - log - ); - return response.ok({ body: { displayAlert: hasData } }); + const anonymousAccessService = getAnonymousAccessService(); + const appState: AppState = { + insecureClusterAlert: { displayAlert }, + anonymousAccess: { + isEnabled: anonymousAccessService?.isAnonymousAccessEnabled ?? false, + accessURLParameters: anonymousAccessService?.accessURLParameters + ? Object.fromEntries(anonymousAccessService.accessURLParameters.entries()) + : null, + }, + }; + return response.ok({ body: appState }); } ); }; diff --git a/src/plugins/security_oss/server/routes/index.ts b/src/plugins/security_oss/server/routes/index.ts index ceff0b12c9cb1..8b6dc6b6f217f 100644 --- a/src/plugins/security_oss/server/routes/index.ts +++ b/src/plugins/security_oss/server/routes/index.ts @@ -17,4 +17,5 @@ * under the License. */ -export { setupDisplayInsecureClusterAlertRoute } from './display_insecure_cluster_alert'; +export { setupAppStateRoute } from './app_state'; +export { setupAnonymousAccessCapabilitiesRoute } from './anonymous_access_capabilities'; diff --git a/src/plugins/security_oss/server/routes/integration_tests/anonymous_access_capabilities.test.ts b/src/plugins/security_oss/server/routes/integration_tests/anonymous_access_capabilities.test.ts new file mode 100644 index 0000000000000..c8c847f628133 --- /dev/null +++ b/src/plugins/security_oss/server/routes/integration_tests/anonymous_access_capabilities.test.ts @@ -0,0 +1,86 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { UnwrapPromise } from '@kbn/utility-types'; +import supertest from 'supertest'; +import { setupServer } from '../../../../../core/server/test_utils'; +import { AnonymousAccessService } from '../../plugin'; +import { setupAnonymousAccessCapabilitiesRoute } from '../anonymous_access_capabilities'; + +type SetupServerReturn = UnwrapPromise>; +const pluginId = Symbol('securityOss'); + +interface SetupOpts { + getAnonymousAccessService?: () => AnonymousAccessService | null; +} + +describe('GET /internal/security_oss/anonymous_access/capabilities', () => { + let server: SetupServerReturn['server']; + let httpSetup: SetupServerReturn['httpSetup']; + + const setupTestServer = async ({ getAnonymousAccessService = () => null }: SetupOpts = {}) => { + ({ server, httpSetup } = await setupServer(pluginId)); + + const router = httpSetup.createRouter('/'); + + setupAnonymousAccessCapabilitiesRoute({ router, getAnonymousAccessService }); + + await server.start(); + }; + + afterEach(async () => { + await server.stop(); + }); + + it('responds with 501 if anonymous access service is provided', async () => { + await setupTestServer(); + + await supertest(httpSetup.server.listener) + .get('/internal/security_oss/anonymous_access/capabilities') + .expect(501, { + statusCode: 501, + error: 'Not Implemented', + message: 'Not Implemented', + }); + }); + + it('returns anonymous access state if anonymous access service is provided', async () => { + await setupTestServer({ + getAnonymousAccessService: () => ({ + isAnonymousAccessEnabled: true, + accessURLParameters: new Map([['auth_provider_hint', 'anonymous1']]), + getCapabilities: jest.fn().mockResolvedValue({ + navLinks: {}, + management: {}, + catalogue: {}, + custom: { something: true }, + }), + }), + }); + + await supertest(httpSetup.server.listener) + .get('/internal/security_oss/anonymous_access/capabilities') + .expect(200, { + navLinks: {}, + management: {}, + catalogue: {}, + custom: { something: true }, + }); + }); +}); diff --git a/src/plugins/security_oss/server/routes/integration_tests/display_insecure_cluster_alert.test.ts b/src/plugins/security_oss/server/routes/integration_tests/app_state.test.ts similarity index 51% rename from src/plugins/security_oss/server/routes/integration_tests/display_insecure_cluster_alert.test.ts rename to src/plugins/security_oss/server/routes/integration_tests/app_state.test.ts index d62a5040be6b3..e68dd40bb01bb 100644 --- a/src/plugins/security_oss/server/routes/integration_tests/display_insecure_cluster_alert.test.ts +++ b/src/plugins/security_oss/server/routes/integration_tests/app_state.test.ts @@ -19,7 +19,8 @@ import { loggingSystemMock } from '../../../../../core/server/mocks'; import { setupServer } from '../../../../../core/server/test_utils'; -import { setupDisplayInsecureClusterAlertRoute } from '../display_insecure_cluster_alert'; +import { AnonymousAccessService } from '../../plugin'; +import { setupAppStateRoute } from '../app_state'; import { ConfigType } from '../../config'; import { BehaviorSubject, of } from 'rxjs'; import { UnwrapPromise } from '@kbn/utility-types'; @@ -33,9 +34,10 @@ interface SetupOpts { config?: ConfigType; displayModifier$?: BehaviorSubject; doesClusterHaveUserData?: ReturnType; + getAnonymousAccessService?: () => AnonymousAccessService | null; } -describe('GET /internal/security_oss/display_insecure_cluster_alert', () => { +describe('GET /internal/security_oss/app_state', () => { let server: SetupServerReturn['server']; let httpSetup: SetupServerReturn['httpSetup']; @@ -43,18 +45,20 @@ describe('GET /internal/security_oss/display_insecure_cluster_alert', () => { config = { showInsecureClusterWarning: true }, displayModifier$ = new BehaviorSubject(true), doesClusterHaveUserData = jest.fn().mockResolvedValue(true), + getAnonymousAccessService = () => null, }: SetupOpts) => { ({ server, httpSetup } = await setupServer(pluginId)); const router = httpSetup.createRouter('/'); const log = loggingSystemMock.createLogger(); - setupDisplayInsecureClusterAlertRoute({ + setupAppStateRoute({ router, log, config$: of(config), displayModifier$, doesClusterHaveUserData, + getAnonymousAccessService, }); await server.start(); @@ -68,28 +72,34 @@ describe('GET /internal/security_oss/display_insecure_cluster_alert', () => { await server.stop(); }); - it('responds `false` if plugin is not configured to display alerts', async () => { + it('responds `insecureClusterAlert.displayAlert == false` if plugin is not configured to display alerts', async () => { await setupTestServer({ config: { showInsecureClusterWarning: false }, }); await supertest(httpSetup.server.listener) - .get('/internal/security_oss/display_insecure_cluster_alert') - .expect(200, { displayAlert: false }); + .get('/internal/security_oss/app_state') + .expect(200, { + insecureClusterAlert: { displayAlert: false }, + anonymousAccess: { isEnabled: false, accessURLParameters: null }, + }); }); - it('responds `false` if cluster does not contain user data', async () => { + it('responds `insecureClusterAlert.displayAlert == false` if cluster does not contain user data', async () => { await setupTestServer({ config: { showInsecureClusterWarning: true }, doesClusterHaveUserData: jest.fn().mockResolvedValue(false), }); await supertest(httpSetup.server.listener) - .get('/internal/security_oss/display_insecure_cluster_alert') - .expect(200, { displayAlert: false }); + .get('/internal/security_oss/app_state') + .expect(200, { + insecureClusterAlert: { displayAlert: false }, + anonymousAccess: { isEnabled: false, accessURLParameters: null }, + }); }); - it('responds `false` if displayModifier$ is set to false', async () => { + it('responds `insecureClusterAlert.displayAlert == false` if displayModifier$ is set to false', async () => { await setupTestServer({ config: { showInsecureClusterWarning: true }, doesClusterHaveUserData: jest.fn().mockResolvedValue(true), @@ -97,19 +107,25 @@ describe('GET /internal/security_oss/display_insecure_cluster_alert', () => { }); await supertest(httpSetup.server.listener) - .get('/internal/security_oss/display_insecure_cluster_alert') - .expect(200, { displayAlert: false }); + .get('/internal/security_oss/app_state') + .expect(200, { + insecureClusterAlert: { displayAlert: false }, + anonymousAccess: { isEnabled: false, accessURLParameters: null }, + }); }); - it('responds `true` if cluster contains user data', async () => { + it('responds `insecureClusterAlert.displayAlert == true` if cluster contains user data', async () => { await setupTestServer({ config: { showInsecureClusterWarning: true }, doesClusterHaveUserData: jest.fn().mockResolvedValue(true), }); await supertest(httpSetup.server.listener) - .get('/internal/security_oss/display_insecure_cluster_alert') - .expect(200, { displayAlert: true }); + .get('/internal/security_oss/app_state') + .expect(200, { + insecureClusterAlert: { displayAlert: true }, + anonymousAccess: { isEnabled: false, accessURLParameters: null }, + }); }); it('responds to changing displayModifier$ values', async () => { @@ -122,13 +138,56 @@ describe('GET /internal/security_oss/display_insecure_cluster_alert', () => { }); await supertest(httpSetup.server.listener) - .get('/internal/security_oss/display_insecure_cluster_alert') - .expect(200, { displayAlert: true }); + .get('/internal/security_oss/app_state') + .expect(200, { + insecureClusterAlert: { displayAlert: true }, + anonymousAccess: { isEnabled: false, accessURLParameters: null }, + }); displayModifier$.next(false); await supertest(httpSetup.server.listener) - .get('/internal/security_oss/display_insecure_cluster_alert') - .expect(200, { displayAlert: false }); + .get('/internal/security_oss/app_state') + .expect(200, { + insecureClusterAlert: { displayAlert: false }, + anonymousAccess: { isEnabled: false, accessURLParameters: null }, + }); + }); + + it('returns anonymous access state if anonymous access service is provided', async () => { + const displayModifier$ = new BehaviorSubject(true); + + await setupTestServer({ + config: { showInsecureClusterWarning: true }, + doesClusterHaveUserData: jest.fn().mockResolvedValue(true), + displayModifier$, + getAnonymousAccessService: () => ({ + isAnonymousAccessEnabled: true, + accessURLParameters: new Map([['auth_provider_hint', 'anonymous1']]), + getCapabilities: jest.fn(), + }), + }); + + await supertest(httpSetup.server.listener) + .get('/internal/security_oss/app_state') + .expect(200, { + insecureClusterAlert: { displayAlert: true }, + anonymousAccess: { + isEnabled: true, + accessURLParameters: { auth_provider_hint: 'anonymous1' }, + }, + }); + + displayModifier$.next(false); + + await supertest(httpSetup.server.listener) + .get('/internal/security_oss/app_state') + .expect(200, { + insecureClusterAlert: { displayAlert: false }, + anonymousAccess: { + isEnabled: true, + accessURLParameters: { auth_provider_hint: 'anonymous1' }, + }, + }); }); }); diff --git a/src/plugins/security_oss/tsconfig.json b/src/plugins/security_oss/tsconfig.json index d211a70f12df3..530e01a034b00 100644 --- a/src/plugins/security_oss/tsconfig.json +++ b/src/plugins/security_oss/tsconfig.json @@ -7,6 +7,6 @@ "declaration": true, "declarationMap": true }, - "include": ["public/**/*", "server/**/*"], + "include": ["common/**/*", "public/**/*", "server/**/*"], "references": [{ "path": "../../core/tsconfig.json" }] } diff --git a/x-pack/plugins/security/server/anonymous_access/anonymous_access_service.mock.ts b/x-pack/plugins/security/server/anonymous_access/anonymous_access_service.mock.ts new file mode 100644 index 0000000000000..55509090af28a --- /dev/null +++ b/x-pack/plugins/security/server/anonymous_access/anonymous_access_service.mock.ts @@ -0,0 +1,17 @@ +/* + * 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 type { AnonymousAccessServiceStart } from './anonymous_access_service'; + +import { capabilitiesServiceMock } from '../../../../../src/core/server/mocks'; + +export const anonymousAccessServiceMock = { + createStart: (): jest.Mocked => ({ + isAnonymousAccessEnabled: false, + accessURLParameters: null, + getCapabilities: jest.fn().mockReturnValue(capabilitiesServiceMock.createCapabilities()), + }), +}; diff --git a/x-pack/plugins/security/server/anonymous_access/anonymous_access_service.test.ts b/x-pack/plugins/security/server/anonymous_access/anonymous_access_service.test.ts new file mode 100644 index 0000000000000..5aee78c368e64 --- /dev/null +++ b/x-pack/plugins/security/server/anonymous_access/anonymous_access_service.test.ts @@ -0,0 +1,255 @@ +/* + * 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 { errors } from '@elastic/elasticsearch'; +import type { Logger } from '../../../../../src/core/server'; +import { ConfigSchema, createConfig } from '../config'; +import { AnonymousAccessService } from './anonymous_access_service'; + +import { + coreMock, + httpServerMock, + loggingSystemMock, + elasticsearchServiceMock, +} from '../../../../../src/core/server/mocks'; +import { spacesMock } from '../../../spaces/server/mocks'; +import { securityMock } from '../mocks'; + +const createSecurityConfig = (config: Record = {}) => { + return createConfig(ConfigSchema.validate(config), loggingSystemMock.createLogger(), { + isTLSEnabled: true, + }); +}; + +describe('AnonymousAccessService', () => { + let service: AnonymousAccessService; + let logger: jest.Mocked; + let getConfigMock: jest.Mock; + beforeEach(() => { + logger = loggingSystemMock.createLogger(); + getConfigMock = jest.fn().mockReturnValue(createSecurityConfig()); + + service = new AnonymousAccessService(logger, getConfigMock); + }); + + describe('#setup()', () => { + it('returns proper contract', () => { + expect(service.setup()).toBeUndefined(); + }); + }); + + describe('#start()', () => { + const getStartParams = () => { + const mockCoreStart = coreMock.createStart(); + return { + spaces: spacesMock.createStart().spacesService, + basePath: mockCoreStart.http.basePath, + capabilities: mockCoreStart.capabilities, + clusterClient: elasticsearchServiceMock.createClusterClient(), + }; + }; + + it('returns proper contract', () => { + service.setup(); + expect(service.start(getStartParams())).toEqual({ + isAnonymousAccessEnabled: false, + accessURLParameters: null, + getCapabilities: expect.any(Function), + }); + }); + + it('returns `isAnonymousAccessEnabled == true` if anonymous provider is enabled', () => { + getConfigMock.mockReturnValue( + createSecurityConfig({ + authc: { + providers: { + anonymous: { + anonymous1: { order: 0, credentials: { username: 'user', password: 'password' } }, + }, + }, + }, + }) + ); + + service.setup(); + expect(service.start(getStartParams())).toEqual({ + isAnonymousAccessEnabled: true, + accessURLParameters: null, + getCapabilities: expect.any(Function), + }); + }); + + it('returns `isAnonymousAccessEnabled == true` and access URL parameters if anonymous provider is enabled, but not default', () => { + getConfigMock.mockReturnValue( + createSecurityConfig({ + authc: { + providers: { + anonymous: { + anonymous1: { order: 0, credentials: { username: 'user', password: 'password' } }, + }, + basic: { basic1: { order: 1 } }, + }, + }, + }) + ); + + service.setup(); + expect(service.start(getStartParams())).toEqual({ + isAnonymousAccessEnabled: true, + accessURLParameters: new Map([['auth_provider_hint', 'anonymous1']]), + getCapabilities: expect.any(Function), + }); + }); + + it('returns `isAnonymousAccessEnabled == false` if anonymous provider defined, but disabled', () => { + getConfigMock.mockReturnValue( + createSecurityConfig({ + authc: { + providers: { + anonymous: { + anonymous1: { + enabled: false, + order: 0, + credentials: { username: 'user', password: 'password' }, + }, + }, + }, + }, + }) + ); + + service.setup(); + expect(service.start(getStartParams())).toEqual({ + isAnonymousAccessEnabled: false, + accessURLParameters: null, + getCapabilities: expect.any(Function), + }); + }); + + describe('#getCapabilities', () => { + beforeEach(() => { + getConfigMock.mockReturnValue( + createSecurityConfig({ + authc: { + providers: { + anonymous: { + anonymous1: { order: 0, credentials: { username: 'user', password: 'password' } }, + }, + }, + }, + }) + ); + }); + + it('returns default capabilities if anonymous access is not enabled', async () => { + getConfigMock.mockReturnValue(createSecurityConfig()); + service.setup(); + + const defaultCapabilities = { navLinks: {}, management: {}, catalogue: {} }; + const startParams = getStartParams(); + startParams.capabilities.resolveCapabilities.mockResolvedValue(defaultCapabilities); + + const mockRequest = httpServerMock.createKibanaRequest({ + headers: { authorization: 'xxx' }, + }); + await expect(service.start(startParams).getCapabilities(mockRequest)).resolves.toEqual( + defaultCapabilities + ); + expect(startParams.capabilities.resolveCapabilities).toHaveBeenCalledTimes(1); + expect(startParams.capabilities.resolveCapabilities).toHaveBeenCalledWith( + expect.objectContaining({ headers: {} }), + { + useDefaultCapabilities: true, + } + ); + }); + + it('returns default capabilities if cannot authenticate anonymous service account', async () => { + service.setup(); + + const defaultCapabilities = { navLinks: {}, management: {}, catalogue: {} }; + const startParams = getStartParams(); + startParams.capabilities.resolveCapabilities.mockResolvedValue(defaultCapabilities); + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 401, body: {} })) + ); + startParams.clusterClient.asScoped.mockReturnValue(mockScopedClusterClient); + + const mockRequest = httpServerMock.createKibanaRequest({ + headers: { authorization: 'xxx' }, + }); + await expect(service.start(startParams).getCapabilities(mockRequest)).resolves.toEqual( + defaultCapabilities + ); + + expect(startParams.clusterClient.asScoped).toHaveBeenCalledTimes(1); + expect(startParams.clusterClient.asScoped).toHaveBeenCalledWith( + expect.objectContaining({ headers: { authorization: 'Basic dXNlcjpwYXNzd29yZA==' } }) + ); + expect(mockScopedClusterClient.asCurrentUser.security.authenticate).toHaveBeenCalledTimes( + 1 + ); + + expect(startParams.capabilities.resolveCapabilities).toHaveBeenCalledTimes(1); + expect(startParams.capabilities.resolveCapabilities).toHaveBeenCalledWith( + expect.objectContaining({ headers: {} }), + { + useDefaultCapabilities: true, + } + ); + }); + + it('fails if cannot retrieve capabilities because of unknown reason', async () => { + service.setup(); + + const failureReason = new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 500, body: {} }) + ); + const startParams = getStartParams(); + startParams.capabilities.resolveCapabilities.mockRejectedValue(failureReason); + + const mockRequest = httpServerMock.createKibanaRequest({ + headers: { authorization: 'xxx' }, + }); + await expect(service.start(startParams).getCapabilities(mockRequest)).rejects.toBe( + failureReason + ); + + expect(startParams.capabilities.resolveCapabilities).toHaveBeenCalledTimes(1); + expect( + startParams.capabilities.resolveCapabilities + ).toHaveBeenCalledWith( + expect.objectContaining({ headers: { authorization: 'Basic dXNlcjpwYXNzd29yZA==' } }), + { useDefaultCapabilities: false } + ); + }); + + it('returns resolved capabilities', async () => { + service.setup(); + + const resolvedCapabilities = { navLinks: {}, management: {}, catalogue: {}, custom: {} }; + const startParams = getStartParams(); + startParams.capabilities.resolveCapabilities.mockResolvedValue(resolvedCapabilities); + + const mockRequest = httpServerMock.createKibanaRequest({ + headers: { authorization: 'xxx' }, + }); + await expect(service.start(startParams).getCapabilities(mockRequest)).resolves.toEqual( + resolvedCapabilities + ); + expect(startParams.capabilities.resolveCapabilities).toHaveBeenCalledTimes(1); + expect( + startParams.capabilities.resolveCapabilities + ).toHaveBeenCalledWith( + expect.objectContaining({ headers: { authorization: 'Basic dXNlcjpwYXNzd29yZA==' } }), + { useDefaultCapabilities: false } + ); + }); + }); + }); +}); diff --git a/x-pack/plugins/security/server/anonymous_access/anonymous_access_service.ts b/x-pack/plugins/security/server/anonymous_access/anonymous_access_service.ts new file mode 100644 index 0000000000000..66b1e91e12bce --- /dev/null +++ b/x-pack/plugins/security/server/anonymous_access/anonymous_access_service.ts @@ -0,0 +1,178 @@ +/* + * 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 type { Request } from '@hapi/hapi'; +import { + CapabilitiesStart, + IBasePath, + KibanaRequest, + Logger, + Capabilities, + IClusterClient, +} from '../../../../../src/core/server'; +import { addSpaceIdToPath } from '../../../spaces/common'; +import type { SpacesServiceStart } from '../../../spaces/server'; +import { AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER } from '../../common/constants'; +import { AnonymousAuthenticationProvider, HTTPAuthorizationHeader } from '../authentication'; +import type { ConfigType } from '../config'; +import { getDetailedErrorMessage, getErrorStatusCode } from '../errors'; + +export interface AnonymousAccessServiceStart { + readonly isAnonymousAccessEnabled: boolean; + // We cannot use `ReadonlyMap` just yet: https://github.com/microsoft/TypeScript/issues/16840 + readonly accessURLParameters: Readonly> | null; + getCapabilities: (request: KibanaRequest) => Promise; +} + +interface AnonymousAccessServiceStartParams { + basePath: IBasePath; + capabilities: CapabilitiesStart; + clusterClient: IClusterClient; + spaces?: SpacesServiceStart; +} + +/** + * Service that manages various aspects of the anonymous access. + */ +export class AnonymousAccessService { + /** + * Indicates whether anonymous access is enabled. + */ + private isAnonymousAccessEnabled = false; + + /** + * Defines HTTP authorization header that should be used to authenticate request. + */ + private httpAuthorizationHeader: HTTPAuthorizationHeader | null = null; + + constructor(private readonly logger: Logger, private readonly getConfig: () => ConfigType) {} + + setup() { + const config = this.getConfig(); + const anonymousProvider = config.authc.sortedProviders.find( + ({ type }) => type === AnonymousAuthenticationProvider.type + ); + + this.isAnonymousAccessEnabled = !!anonymousProvider; + this.httpAuthorizationHeader = anonymousProvider + ? AnonymousAuthenticationProvider.createHTTPAuthorizationHeader( + (config.authc.providers.anonymous![anonymousProvider.name] as Record) + .credentials + ) + : null; + } + + start({ + basePath, + capabilities, + clusterClient, + spaces, + }: AnonymousAccessServiceStartParams): AnonymousAccessServiceStart { + const config = this.getConfig(); + const anonymousProvider = config.authc.sortedProviders.find( + ({ type }) => type === AnonymousAuthenticationProvider.type + ); + // We don't need to add any special parameters to the URL if any of the following is true: + // * anonymous provider isn't enabled + // * anonymous provider is enabled, but it's a default authentication mechanism + const anonymousIsDefault = + !config.authc.selector.enabled && anonymousProvider === config.authc.sortedProviders[0]; + + return { + isAnonymousAccessEnabled: this.isAnonymousAccessEnabled, + accessURLParameters: + anonymousProvider && !anonymousIsDefault + ? Object.freeze( + new Map([[AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER, anonymousProvider.name]]) + ) + : null, + getCapabilities: async (request) => { + this.logger.debug('Retrieving capabilities of the anonymous service account.'); + + let useDefaultCapabilities = false; + if (!this.isAnonymousAccessEnabled) { + this.logger.warn( + 'Default capabilities will be returned since anonymous access is not enabled.' + ); + useDefaultCapabilities = true; + } else if (!(await this.canAuthenticateAnonymousServiceAccount(clusterClient))) { + this.logger.warn( + `Default capabilities will be returned since anonymous service account cannot authenticate.` + ); + useDefaultCapabilities = true; + } + + // We should use credentials of the anonymous service account instead of credentials of the + // current user to get capabilities relevant to the anonymous access itself. + const fakeAnonymousRequest = this.createFakeAnonymousRequest({ + authenticateRequest: !useDefaultCapabilities, + }); + const spaceId = spaces?.getSpaceId(request); + if (spaceId) { + basePath.set(fakeAnonymousRequest, addSpaceIdToPath('/', spaceId)); + } + + try { + return await capabilities.resolveCapabilities(fakeAnonymousRequest, { + useDefaultCapabilities, + }); + } catch (err) { + this.logger.error( + `Failed to retrieve anonymous service account capabilities: ${getDetailedErrorMessage( + err + )}` + ); + throw err; + } + }, + }; + } + + /** + * Checks if anonymous service account can authenticate to Elasticsearch using currently configured credentials. + * @param clusterClient + */ + private async canAuthenticateAnonymousServiceAccount(clusterClient: IClusterClient) { + try { + await clusterClient + .asScoped(this.createFakeAnonymousRequest({ authenticateRequest: true })) + .asCurrentUser.security.authenticate(); + } catch (err) { + this.logger.warn( + `Failed to authenticate anonymous service account: ${getDetailedErrorMessage(err)}` + ); + + if (getErrorStatusCode(err) === 401) { + return false; + } + throw err; + } + + return true; + } + + /** + * Creates a fake Kibana request optionally attributed with the anonymous service account + * credentials to get the list of capabilities. + * @param authenticateRequest Indicates whether or not we should include authorization header with + * anonymous service account credentials. + */ + private createFakeAnonymousRequest({ authenticateRequest }: { authenticateRequest: boolean }) { + return KibanaRequest.from(({ + headers: + authenticateRequest && this.httpAuthorizationHeader + ? { authorization: this.httpAuthorizationHeader.toString() } + : {}, + // This flag is essential for the security capability switcher that relies on it to decide if + // it should perform a privileges check or automatically disable all capabilities. + auth: { isAuthenticated: authenticateRequest }, + path: '/', + route: { settings: {} }, + url: { href: '/' }, + raw: { req: { url: '/' } }, + } as unknown) as Request); + } +} diff --git a/x-pack/plugins/security/server/anonymous_access/index.ts b/x-pack/plugins/security/server/anonymous_access/index.ts new file mode 100644 index 0000000000000..62a5c4f6b27ae --- /dev/null +++ b/x-pack/plugins/security/server/anonymous_access/index.ts @@ -0,0 +1,7 @@ +/* + * 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 { AnonymousAccessService, AnonymousAccessServiceStart } from './anonymous_access_service'; diff --git a/x-pack/plugins/security/server/authentication/index.ts b/x-pack/plugins/security/server/authentication/index.ts index 839596eafcc5b..c87a02c9545c1 100644 --- a/x-pack/plugins/security/server/authentication/index.ts +++ b/x-pack/plugins/security/server/authentication/index.ts @@ -19,6 +19,7 @@ export { TokenAuthenticationProvider, SAMLAuthenticationProvider, OIDCAuthenticationProvider, + AnonymousAuthenticationProvider, } from './providers'; export { BasicHTTPAuthorizationHeaderCredentials, diff --git a/x-pack/plugins/security/server/authentication/providers/anonymous.ts b/x-pack/plugins/security/server/authentication/providers/anonymous.ts index 4d1b5f4a74b2f..1585b0592b356 100644 --- a/x-pack/plugins/security/server/authentication/providers/anonymous.ts +++ b/x-pack/plugins/security/server/authentication/providers/anonymous.ts @@ -70,7 +70,42 @@ export class AnonymousAuthenticationProvider extends BaseAuthenticationProvider * Defines HTTP authorization header that should be used to authenticate request. It isn't defined * if provider should rely on Elasticsearch native anonymous access. */ - private readonly httpAuthorizationHeader?: HTTPAuthorizationHeader; + private readonly httpAuthorizationHeader: HTTPAuthorizationHeader | null; + + /** + * Create authorization header for the specified credentials. Returns `null` if credentials imply + * Elasticsearch anonymous user. + * @param credentials Credentials to create HTTP authorization header for. + */ + public static createHTTPAuthorizationHeader( + credentials: Readonly< + ElasticsearchAnonymousUserCredentials | UsernameAndPasswordCredentials | APIKeyCredentials + > + ) { + if (credentials === 'elasticsearch_anonymous_user') { + return null; + } + + if (isAPIKeyCredentials(credentials)) { + return new HTTPAuthorizationHeader( + 'ApiKey', + typeof credentials.apiKey === 'string' + ? credentials.apiKey + : new BasicHTTPAuthorizationHeaderCredentials( + credentials.apiKey.id, + credentials.apiKey.key + ).toString() + ); + } + + return new HTTPAuthorizationHeader( + 'Basic', + new BasicHTTPAuthorizationHeaderCredentials( + credentials.username, + credentials.password + ).toString() + ); + } constructor( protected readonly options: Readonly, @@ -93,25 +128,13 @@ export class AnonymousAuthenticationProvider extends BaseAuthenticationProvider ); } else if (isAPIKeyCredentials(credentials)) { this.logger.debug('Anonymous requests will be authenticated via API key.'); - this.httpAuthorizationHeader = new HTTPAuthorizationHeader( - 'ApiKey', - typeof credentials.apiKey === 'string' - ? credentials.apiKey - : new BasicHTTPAuthorizationHeaderCredentials( - credentials.apiKey.id, - credentials.apiKey.key - ).toString() - ); } else { this.logger.debug('Anonymous requests will be authenticated via username and password.'); - this.httpAuthorizationHeader = new HTTPAuthorizationHeader( - 'Basic', - new BasicHTTPAuthorizationHeaderCredentials( - credentials.username, - credentials.password - ).toString() - ); } + + this.httpAuthorizationHeader = AnonymousAuthenticationProvider.createHTTPAuthorizationHeader( + credentials + ); } /** diff --git a/x-pack/plugins/security/server/errors.test.ts b/x-pack/plugins/security/server/errors.test.ts index 630ab5b9295db..2a6ee2dbcb325 100644 --- a/x-pack/plugins/security/server/errors.test.ts +++ b/x-pack/plugins/security/server/errors.test.ts @@ -5,8 +5,10 @@ */ import Boom from '@hapi/boom'; -import { errors as esErrors } from 'elasticsearch'; +import { errors as esErrors } from '@elastic/elasticsearch'; +import { errors as legacyESErrors } from 'elasticsearch'; import * as errors from './errors'; +import { securityMock } from './mocks'; describe('lib/errors', () => { describe('#wrapError', () => { @@ -55,9 +57,22 @@ describe('lib/errors', () => { expect(errors.getErrorStatusCode(Boom.unauthorized())).toBe(401); }); - it('extracts status code from Elasticsearch client error', () => { - expect(errors.getErrorStatusCode(new esErrors.BadRequest())).toBe(400); - expect(errors.getErrorStatusCode(new esErrors.AuthenticationException())).toBe(401); + it('extracts status code from Elasticsearch client response error', () => { + expect( + errors.getErrorStatusCode( + new esErrors.ResponseError(securityMock.createApiResponse({ statusCode: 400, body: {} })) + ) + ).toBe(400); + expect( + errors.getErrorStatusCode( + new esErrors.ResponseError(securityMock.createApiResponse({ statusCode: 401, body: {} })) + ) + ).toBe(401); + }); + + it('extracts status code from legacy Elasticsearch client error', () => { + expect(errors.getErrorStatusCode(new legacyESErrors.BadRequest())).toBe(400); + expect(errors.getErrorStatusCode(new legacyESErrors.AuthenticationException())).toBe(401); }); it('extracts status code from `status` property', () => { @@ -65,4 +80,53 @@ describe('lib/errors', () => { expect(errors.getErrorStatusCode({ statusText: 'Unauthorized', status: 401 })).toBe(401); }); }); + + describe('#getDetailedErrorMessage', () => { + it('extracts body payload from Boom error', () => { + expect(errors.getDetailedErrorMessage(Boom.badRequest())).toBe( + JSON.stringify({ statusCode: 400, error: 'Bad Request', message: 'Bad Request' }) + ); + expect(errors.getDetailedErrorMessage(Boom.unauthorized())).toBe( + JSON.stringify({ statusCode: 401, error: 'Unauthorized', message: 'Unauthorized' }) + ); + + const customBoomError = Boom.unauthorized(); + customBoomError.output.payload = { + statusCode: 401, + error: 'some-weird-error', + message: 'some-weird-message', + }; + expect(errors.getDetailedErrorMessage(customBoomError)).toBe( + JSON.stringify({ + statusCode: 401, + error: 'some-weird-error', + message: 'some-weird-message', + }) + ); + }); + + it('extracts body from Elasticsearch client response error', () => { + expect( + errors.getDetailedErrorMessage( + new esErrors.ResponseError( + securityMock.createApiResponse({ + statusCode: 401, + body: { field1: 'value-1', field2: 'value-2' }, + }) + ) + ) + ).toBe(JSON.stringify({ field1: 'value-1', field2: 'value-2' })); + }); + + it('extracts status code from legacy Elasticsearch client error', () => { + expect(errors.getDetailedErrorMessage(new legacyESErrors.BadRequest())).toBe('Bad Request'); + expect(errors.getDetailedErrorMessage(new legacyESErrors.AuthenticationException())).toBe( + 'Authentication Exception' + ); + }); + + it('extracts `message` property', () => { + expect(errors.getDetailedErrorMessage(new Error('some-message'))).toBe('some-message'); + }); + }); }); diff --git a/x-pack/plugins/security/server/errors.ts b/x-pack/plugins/security/server/errors.ts index 9c177c3916faf..3b49b40d26559 100644 --- a/x-pack/plugins/security/server/errors.ts +++ b/x-pack/plugins/security/server/errors.ts @@ -5,7 +5,8 @@ */ import Boom from '@hapi/boom'; -import { CustomHttpResponseOptions, ResponseError } from '../../../../src/core/server'; +import { errors } from '@elastic/elasticsearch'; +import type { CustomHttpResponseOptions, ResponseError } from '../../../../src/core/server'; export function wrapError(error: any) { return Boom.boomify(error, { statusCode: getErrorStatusCode(error) }); @@ -29,5 +30,27 @@ export function wrapIntoCustomErrorResponse(error: any) { * @param error Error instance to extract status code from. */ export function getErrorStatusCode(error: any): number { + if (error instanceof errors.ResponseError) { + return error.statusCode; + } + return Boom.isBoom(error) ? error.output.statusCode : error.statusCode || error.status; } + +/** + * Extracts detailed error message from Boom and Elasticsearch "native" errors. It's supposed to be + * only logged on the server side and never returned to the client as it may contain sensitive + * information. + * @param error Error instance to extract message from. + */ +export function getDetailedErrorMessage(error: any): string { + if (error instanceof errors.ResponseError) { + return JSON.stringify(error.body); + } + + if (Boom.isBoom(error)) { + return JSON.stringify(error.output.payload); + } + + return error.message; +} diff --git a/x-pack/plugins/security/server/plugin.test.ts b/x-pack/plugins/security/server/plugin.test.ts index b9615eed990f0..54efdbdccbb77 100644 --- a/x-pack/plugins/security/server/plugin.test.ts +++ b/x-pack/plugins/security/server/plugin.test.ts @@ -64,66 +64,65 @@ describe('Security Plugin', () => { }); describe('setup()', () => { - it('exposes proper contract', async () => { - await expect(plugin.setup(mockCoreSetup, mockSetupDependencies)).resolves - .toMatchInlineSnapshot(` - Object { - "audit": Object { - "asScoped": [Function], - "getLogger": [Function], - }, - "authc": Object { - "getCurrentUser": [Function], - }, - "authz": Object { - "actions": Actions { - "alerting": AlertingActions { - "prefix": "alerting:version:", - }, - "api": ApiActions { - "prefix": "api:version:", - }, - "app": AppActions { - "prefix": "app:version:", - }, - "login": "login:", - "savedObject": SavedObjectActions { - "prefix": "saved_object:version:", - }, - "space": SpaceActions { - "prefix": "space:version:", - }, - "ui": UIActions { - "prefix": "ui:version:", - }, - "version": "version:version", - "versionNumber": "version", - }, - "checkPrivilegesDynamicallyWithRequest": [Function], - "checkPrivilegesWithRequest": [Function], - "mode": Object { - "useRbacForRequest": [Function], - }, - }, - "license": Object { - "features$": Observable { - "_isScalar": false, - "operator": MapOperator { - "project": [Function], - "thisArg": undefined, - }, - "source": Observable { - "_isScalar": false, - "_subscribe": [Function], - }, - }, - "getFeatures": [Function], - "getType": [Function], - "isEnabled": [Function], - "isLicenseAvailable": [Function], - }, - } - `); + it('exposes proper contract', () => { + expect(plugin.setup(mockCoreSetup, mockSetupDependencies)).toMatchInlineSnapshot(` + Object { + "audit": Object { + "asScoped": [Function], + "getLogger": [Function], + }, + "authc": Object { + "getCurrentUser": [Function], + }, + "authz": Object { + "actions": Actions { + "alerting": AlertingActions { + "prefix": "alerting:version:", + }, + "api": ApiActions { + "prefix": "api:version:", + }, + "app": AppActions { + "prefix": "app:version:", + }, + "login": "login:", + "savedObject": SavedObjectActions { + "prefix": "saved_object:version:", + }, + "space": SpaceActions { + "prefix": "space:version:", + }, + "ui": UIActions { + "prefix": "ui:version:", + }, + "version": "version:version", + "versionNumber": "version", + }, + "checkPrivilegesDynamicallyWithRequest": [Function], + "checkPrivilegesWithRequest": [Function], + "mode": Object { + "useRbacForRequest": [Function], + }, + }, + "license": Object { + "features$": Observable { + "_isScalar": false, + "operator": MapOperator { + "project": [Function], + "thisArg": undefined, + }, + "source": Observable { + "_isScalar": false, + "_subscribe": [Function], + }, + }, + "getFeatures": [Function], + "getType": [Function], + "isEnabled": [Function], + "isLicenseAvailable": [Function], + }, + } + `); }); }); diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index 450de2fc31329..1016221cb719d 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { combineLatest } from 'rxjs'; -import { first, map } from 'rxjs/operators'; +import { combineLatest, Subscription } from 'rxjs'; +import { map } from 'rxjs/operators'; import { TypeOf } from '@kbn/config-schema'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { SecurityOssPluginSetup } from 'src/plugins/security_oss/server'; @@ -30,7 +30,8 @@ import { AuthenticationServiceStart, } from './authentication'; import { AuthorizationService, AuthorizationServiceSetup } from './authorization'; -import { ConfigSchema, createConfig } from './config'; +import { AnonymousAccessService, AnonymousAccessServiceStart } from './anonymous_access'; +import { ConfigSchema, ConfigType, createConfig } from './config'; import { defineRoutes } from './routes'; import { SecurityLicenseService, SecurityLicense } from '../common/licensing'; import { setupSavedObjects } from './saved_objects'; @@ -103,9 +104,26 @@ export interface PluginStartDependencies { */ export class Plugin { private readonly logger: Logger; - private securityLicenseService?: SecurityLicenseService; private authenticationStart?: AuthenticationServiceStart; private authorizationSetup?: AuthorizationServiceSetup; + private anonymousAccessStart?: AnonymousAccessServiceStart; + private configSubscription?: Subscription; + + private config?: ConfigType; + private readonly getConfig = () => { + if (!this.config) { + throw new Error('Config is not available.'); + } + return this.config; + }; + + private kibanaIndexName?: string; + private readonly getKibanaIndexName = () => { + if (!this.kibanaIndexName) { + throw new Error('Kibana index name is not available.'); + } + return this.kibanaIndexName; + }; private readonly featureUsageService = new SecurityFeatureUsageService(); private featureUsageServiceStart?: SecurityFeatureUsageServiceStart; @@ -117,6 +135,7 @@ export class Plugin { }; private readonly auditService = new AuditService(this.initializerContext.logger.get('audit')); + private readonly securityLicenseService = new SecurityLicenseService(); private readonly authorizationService = new AuthorizationService(); private readonly elasticsearchService = new ElasticsearchService( this.initializerContext.logger.get('elasticsearch') @@ -127,12 +146,16 @@ export class Plugin { private readonly authenticationService = new AuthenticationService( this.initializerContext.logger.get('authentication') ); + private readonly anonymousAccessService = new AnonymousAccessService( + this.initializerContext.logger.get('anonymous-access'), + this.getConfig + ); constructor(private readonly initializerContext: PluginInitializerContext) { this.logger = this.initializerContext.logger.get(); } - public async setup( + public setup( core: CoreSetup, { features, @@ -143,7 +166,7 @@ export class Plugin { spaces, }: PluginSetupDependencies ) { - const [config, legacyConfig] = await combineLatest([ + this.configSubscription = combineLatest([ this.initializerContext.config.create>().pipe( map((rawConfig) => createConfig(rawConfig, this.initializerContext.logger.get('config'), { @@ -152,9 +175,13 @@ export class Plugin { ) ), this.initializerContext.config.legacy.globalConfig$, - ]) - .pipe(first()) - .toPromise(); + ]).subscribe(([config, { kibana }]) => { + this.config = config; + this.kibanaIndexName = kibana.index; + }); + + const config = this.getConfig(); + const kibanaIndexName = this.getKibanaIndexName(); // A subset of `start` services we need during `setup`. const startServicesPromise = core.getStartServices().then(([coreServices, depsServices]) => ({ @@ -162,7 +189,6 @@ export class Plugin { features: depsServices.features, })); - this.securityLicenseService = new SecurityLicenseService(); const { license } = this.securityLicenseService.setup({ license$: licensing.license$, }); @@ -172,6 +198,13 @@ export class Plugin { const showInsecureClusterWarning = !allowRbac; securityOss.showInsecureClusterWarning$.next(showInsecureClusterWarning); }); + + securityOss.setAnonymousAccessServiceProvider(() => { + if (!this.anonymousAccessStart) { + throw new Error('AnonymousAccess service is not started!'); + } + return this.anonymousAccessStart; + }); } securityFeatures.forEach((securityFeature) => @@ -192,7 +225,7 @@ export class Plugin { config, clusterClient, http: core.http, - kibanaIndexName: legacyConfig.kibana.index, + kibanaIndexName, taskManager, }); @@ -220,6 +253,8 @@ export class Plugin { session, }); + this.anonymousAccessService.setup(); + this.authorizationSetup = this.authorizationService.setup({ http: core.http, capabilities: core.capabilities, @@ -227,7 +262,7 @@ export class Plugin { startServicesPromise.then(({ elasticsearch }) => elasticsearch.client), license, loggers: this.initializerContext.logger, - kibanaIndexName: legacyConfig.kibana.index, + kibanaIndexName, packageVersion: this.initializerContext.env.packageInfo.version, buildNumber: this.initializerContext.env.packageInfo.buildNum, getSpacesService: () => spaces?.spacesService, @@ -290,7 +325,10 @@ export class Plugin { }); } - public start(core: CoreStart, { features, licensing, taskManager }: PluginStartDependencies) { + public start( + core: CoreStart, + { features, licensing, taskManager, spaces }: PluginStartDependencies + ) { this.logger.debug('Starting plugin'); this.featureUsageServiceStart = this.featureUsageService.start({ @@ -308,6 +346,13 @@ export class Plugin { this.authorizationService.start({ features, clusterClient, online$: watchOnlineStatus$() }); + this.anonymousAccessStart = this.anonymousAccessService.start({ + capabilities: core.capabilities, + clusterClient, + basePath: core.http.basePath, + spaces: spaces?.spacesService, + }); + return Object.freeze({ authc: { apiKeys: this.authenticationStart.apiKeys, @@ -326,15 +371,24 @@ export class Plugin { public stop() { this.logger.debug('Stopping plugin'); - if (this.securityLicenseService) { - this.securityLicenseService.stop(); - this.securityLicenseService = undefined; + if (this.configSubscription) { + this.configSubscription.unsubscribe(); + this.configSubscription = undefined; } if (this.featureUsageServiceStart) { this.featureUsageServiceStart = undefined; } + if (this.authenticationStart) { + this.authenticationStart = undefined; + } + + if (this.anonymousAccessStart) { + this.anonymousAccessStart = undefined; + } + + this.securityLicenseService.stop(); this.auditService.stop(); this.authorizationService.stop(); this.elasticsearchService.stop(); diff --git a/x-pack/test/security_api_integration/tests/anonymous/capabilities.ts b/x-pack/test/security_api_integration/tests/anonymous/capabilities.ts new file mode 100644 index 0000000000000..f2f562c0c29c0 --- /dev/null +++ b/x-pack/test/security_api_integration/tests/anonymous/capabilities.ts @@ -0,0 +1,395 @@ +/* + * 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 { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const config = getService('config'); + const security = getService('security'); + const spaces = getService('spaces'); + + const isElasticsearchAnonymousAccessEnabled = (config.get( + 'esTestCluster.serverArgs' + ) as string[]).some((setting) => setting.startsWith('xpack.security.authc.anonymous')); + + async function getAnonymousCapabilities(spaceId?: string) { + const apiResponse = await supertest + .get(`${spaceId ? `/s/${spaceId}` : ''}/internal/security_oss/anonymous_access/capabilities`) + .expect(200); + + return Object.fromEntries( + Object.entries(apiResponse.body).filter( + ([key]) => + key === 'discover' || key === 'dashboard' || key === 'visualize' || key === 'maps' + ) + ); + } + + describe('Anonymous capabilities', () => { + before(async () => { + await spaces.create({ + id: 'space-a', + name: 'space-a', + disabledFeatures: ['discover', 'visualize'], + }); + await spaces.create({ + id: 'space-b', + name: 'space-b', + disabledFeatures: ['dashboard', 'maps'], + }); + }); + + after(async () => { + await spaces.delete('space-a'); + await spaces.delete('space-b'); + }); + + describe('without anonymous service account', () => { + it('all capabilities should be disabled', async () => { + expectSnapshot(await getAnonymousCapabilities()).toMatchInline(` + Object { + "dashboard": Object { + "createNew": false, + "createShortUrl": false, + "saveQuery": false, + "show": false, + "showWriteControls": false, + "storeSearchSession": false, + }, + "discover": Object { + "createShortUrl": false, + "save": false, + "saveQuery": false, + "show": false, + "storeSearchSession": false, + }, + "maps": Object { + "save": false, + "saveQuery": false, + "show": false, + }, + "visualize": Object { + "createShortUrl": false, + "delete": false, + "save": false, + "saveQuery": false, + "show": false, + }, + } + `); + expectSnapshot(await getAnonymousCapabilities('space-a')).toMatchInline(` + Object { + "dashboard": Object { + "createNew": false, + "createShortUrl": false, + "saveQuery": false, + "show": false, + "showWriteControls": false, + "storeSearchSession": false, + }, + "discover": Object { + "createShortUrl": false, + "save": false, + "saveQuery": false, + "show": false, + "storeSearchSession": false, + }, + "maps": Object { + "save": false, + "saveQuery": false, + "show": false, + }, + "visualize": Object { + "createShortUrl": false, + "delete": false, + "save": false, + "saveQuery": false, + "show": false, + }, + } + `); + expectSnapshot(await getAnonymousCapabilities('space-b')).toMatchInline(` + Object { + "dashboard": Object { + "createNew": false, + "createShortUrl": false, + "saveQuery": false, + "show": false, + "showWriteControls": false, + "storeSearchSession": false, + }, + "discover": Object { + "createShortUrl": false, + "save": false, + "saveQuery": false, + "show": false, + "storeSearchSession": false, + }, + "maps": Object { + "save": false, + "saveQuery": false, + "show": false, + }, + "visualize": Object { + "createShortUrl": false, + "delete": false, + "save": false, + "saveQuery": false, + "show": false, + }, + } + `); + }); + }); + + describe('with anonymous service account without roles', () => { + if (!isElasticsearchAnonymousAccessEnabled) { + before(async () => { + await security.user.create('anonymous_user', { + password: 'changeme', + roles: [], + full_name: 'Guest', + }); + }); + + after(async () => { + await security.user.delete('anonymous_user'); + }); + } + + it('all capabilities should be disabled', async () => { + expectSnapshot(await getAnonymousCapabilities()).toMatchInline(` + Object { + "dashboard": Object { + "createNew": false, + "createShortUrl": false, + "saveQuery": false, + "show": false, + "showWriteControls": false, + "storeSearchSession": false, + }, + "discover": Object { + "createShortUrl": false, + "save": false, + "saveQuery": false, + "show": false, + "storeSearchSession": false, + }, + "maps": Object { + "save": false, + "saveQuery": false, + "show": false, + }, + "visualize": Object { + "createShortUrl": false, + "delete": false, + "save": false, + "saveQuery": false, + "show": false, + }, + } + `); + expectSnapshot(await getAnonymousCapabilities('space-a')).toMatchInline(` + Object { + "dashboard": Object { + "createNew": false, + "createShortUrl": false, + "saveQuery": false, + "show": false, + "showWriteControls": false, + "storeSearchSession": false, + }, + "discover": Object { + "createShortUrl": false, + "save": false, + "saveQuery": false, + "show": false, + "storeSearchSession": false, + }, + "maps": Object { + "save": false, + "saveQuery": false, + "show": false, + }, + "visualize": Object { + "createShortUrl": false, + "delete": false, + "save": false, + "saveQuery": false, + "show": false, + }, + } + `); + expectSnapshot(await getAnonymousCapabilities('space-b')).toMatchInline(` + Object { + "dashboard": Object { + "createNew": false, + "createShortUrl": false, + "saveQuery": false, + "show": false, + "showWriteControls": false, + "storeSearchSession": false, + }, + "discover": Object { + "createShortUrl": false, + "save": false, + "saveQuery": false, + "show": false, + "storeSearchSession": false, + }, + "maps": Object { + "save": false, + "saveQuery": false, + "show": false, + }, + "visualize": Object { + "createShortUrl": false, + "delete": false, + "save": false, + "saveQuery": false, + "show": false, + }, + } + `); + }); + }); + + describe('with properly configured anonymous service account', () => { + before(async () => { + await security.role.create('anonymous_role', { + elasticsearch: { cluster: [], indices: [], run_as: [] }, + kibana: [ + { spaces: ['default'], base: ['read'], feature: {} }, + { spaces: ['space-a'], base: [], feature: { discover: ['read'], maps: ['read'] } }, + { + spaces: ['space-b'], + base: [], + feature: { dashboard: ['read'], visualize: ['read'] }, + }, + ], + }); + + if (!isElasticsearchAnonymousAccessEnabled) { + await security.user.create('anonymous_user', { + password: 'changeme', + roles: ['anonymous_role'], + full_name: 'Guest', + }); + } + }); + + after(async () => { + await security.role.delete('anonymous_role'); + + if (!isElasticsearchAnonymousAccessEnabled) { + await security.user.delete('anonymous_user'); + } + }); + + it('capabilities should be properly defined', async () => { + // Discover, dashboards, visualizations and maps should be available in read-only mode. + expectSnapshot(await getAnonymousCapabilities()).toMatchInline(` + Object { + "dashboard": Object { + "createNew": false, + "createShortUrl": false, + "saveQuery": false, + "show": true, + "showWriteControls": false, + "storeSearchSession": false, + }, + "discover": Object { + "createShortUrl": false, + "save": false, + "saveQuery": false, + "show": true, + "storeSearchSession": false, + }, + "maps": Object { + "save": false, + "saveQuery": false, + "show": true, + }, + "visualize": Object { + "createShortUrl": false, + "delete": false, + "save": false, + "saveQuery": false, + "show": true, + }, + } + `); + + // Only maps should be available in read-only mode, the rest should be disabled. + expectSnapshot(await getAnonymousCapabilities('space-a')).toMatchInline(` + Object { + "dashboard": Object { + "createNew": false, + "createShortUrl": false, + "saveQuery": false, + "show": false, + "showWriteControls": false, + "storeSearchSession": false, + }, + "discover": Object { + "createShortUrl": false, + "save": false, + "saveQuery": false, + "show": false, + "storeSearchSession": false, + }, + "maps": Object { + "save": false, + "saveQuery": false, + "show": true, + }, + "visualize": Object { + "createShortUrl": false, + "delete": false, + "save": false, + "saveQuery": false, + "show": false, + }, + } + `); + + // Only visualizations should be available in read-only mode, the rest should be disabled. + expectSnapshot(await getAnonymousCapabilities('space-b')).toMatchInline(` + Object { + "dashboard": Object { + "createNew": false, + "createShortUrl": false, + "saveQuery": false, + "show": false, + "showWriteControls": false, + "storeSearchSession": false, + }, + "discover": Object { + "createShortUrl": false, + "save": false, + "saveQuery": false, + "show": false, + "storeSearchSession": false, + }, + "maps": Object { + "save": false, + "saveQuery": false, + "show": false, + }, + "visualize": Object { + "createShortUrl": false, + "delete": false, + "save": false, + "saveQuery": false, + "show": true, + }, + } + `); + }); + }); + }); +} diff --git a/x-pack/test/security_api_integration/tests/anonymous/index.ts b/x-pack/test/security_api_integration/tests/anonymous/index.ts index 3819d26ae5efa..53fc67dad436f 100644 --- a/x-pack/test/security_api_integration/tests/anonymous/index.ts +++ b/x-pack/test/security_api_integration/tests/anonymous/index.ts @@ -10,5 +10,6 @@ export default function ({ loadTestFile }: FtrProviderContext) { describe('security APIs - Anonymous access', function () { this.tags('ciGroup6'); loadTestFile(require.resolve('./login')); + loadTestFile(require.resolve('./capabilities')); }); }