From ad82f4ca8748ec9a29b76de87af394f9506e6f47 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Thu, 19 Nov 2020 08:20:19 +0100 Subject: [PATCH 1/7] Expose AnonymousAccess service through Security OSS plugin. --- src/plugins/security_oss/public/plugin.ts | 31 ++++ .../anonymous_access_service.mock.ts | 21 +++ .../anonymous_access_service.ts | 140 ++++++++++++++++++ .../security/server/anonymous_access/index.ts | 11 ++ .../security/server/authentication/index.ts | 1 + .../authentication/providers/anonymous.ts | 57 ++++--- x-pack/plugins/security/server/errors.ts | 25 +++- x-pack/plugins/security/server/plugin.ts | 89 +++++++++-- .../can_access_saved_object_type.test.ts | 105 +++++++++++++ .../can_access_saved_object_type.ts | 56 +++++++ .../server/routes/anonymous_access/index.ts | 12 ++ .../security/server/routes/index.mock.ts | 4 + .../plugins/security/server/routes/index.ts | 4 + 13 files changed, 522 insertions(+), 34 deletions(-) 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.ts create mode 100644 x-pack/plugins/security/server/anonymous_access/index.ts create mode 100644 x-pack/plugins/security/server/routes/anonymous_access/can_access_saved_object_type.test.ts create mode 100644 x-pack/plugins/security/server/routes/anonymous_access/can_access_saved_object_type.ts create mode 100644 x-pack/plugins/security/server/routes/anonymous_access/index.ts diff --git a/src/plugins/security_oss/public/plugin.ts b/src/plugins/security_oss/public/plugin.ts index 2f3eed0bde5eb..74e2b40b060e7 100644 --- a/src/plugins/security_oss/public/plugin.ts +++ b/src/plugins/security_oss/public/plugin.ts @@ -25,12 +25,28 @@ import { InsecureClusterServiceStart, } from './insecure_cluster_service'; +export interface SavedObjectTypeAnonymousAccess { + /** + * Indicates whether anonymous user can access particular Saved Object type (e.g. dashboard, map etc.). + */ + canAccess: boolean; + /** + * A map of query string parameters that should be specified in URL so that anonymous user can use + * to automatically log in to Kibana and access particular Saved Object type. + */ + accessURLParameters: Record | null; +} + export interface SecurityOssPluginSetup { insecureCluster: InsecureClusterServiceSetup; } export interface SecurityOssPluginStart { insecureCluster: InsecureClusterServiceStart; + anonymousAccess: { + isAnonymousAccessEnabled: boolean; + canAccessSavedObjectType: (savedObjectType: string) => Promise; + }; } export class SecurityOssPlugin @@ -51,8 +67,23 @@ export class SecurityOssPlugin } public start(core: CoreStart) { + const isAnonymousAccessEnabled = !!core.application.capabilities.security?.anonymousAccess; return { insecureCluster: this.insecureClusterService.start({ core }), + anonymousAccess: { + isAnonymousAccessEnabled, + async canAccessSavedObjectType(savedObjectType: string) { + if (isAnonymousAccessEnabled) { + return await core.http.get( + `/internal/security/anonymous_access/_can_access_saved_object_type?type=${encodeURIComponent( + savedObjectType + )}` + ); + } + + return { canAccess: false, accessURLParameters: null }; + }, + }, }; } } 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..c1f4b48e39743 --- /dev/null +++ b/x-pack/plugins/security/server/anonymous_access/anonymous_access_service.mock.ts @@ -0,0 +1,21 @@ +/* + * 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 { + AnonymousAccessServiceSetup, + AnonymousAccessServiceStart, +} from './anonymous_access_service'; + +export const anonymousAccessServiceMock = { + createSetup: (): jest.Mocked => ({ + isAnonymousAccessEnabled: false, + }), + createStart: (): jest.Mocked => ({ + isAnonymousAccessEnabled: false, + accessURLParameters: null, + isSavedObjectTypeAccessibleAnonymously: jest.fn(), + }), +}; 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..7952f40d52025 --- /dev/null +++ b/x-pack/plugins/security/server/anonymous_access/anonymous_access_service.ts @@ -0,0 +1,140 @@ +/* + * 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 { IBasePath, KibanaRequest, Logger } 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 } from '../authentication'; +import type { AuthorizationServiceSetup } from '../authorization'; +import type { ConfigType } from '../config'; + +export interface AnonymousAccessServiceSetup { + readonly isAnonymousAccessEnabled: boolean; +} + +export interface AnonymousAccessServiceStart { + readonly isAnonymousAccessEnabled: boolean; + // We cannot use `ReadonlyMap` just yet: https://github.com/microsoft/TypeScript/issues/16840 + readonly accessURLParameters: Readonly> | null; + isSavedObjectTypeAccessibleAnonymously: ( + request: KibanaRequest, + savedObjectType: string + ) => Promise; +} + +interface AnonymousAccessServiceStartParams { + authz: Pick; + basePath: IBasePath; + spaces?: SpacesServiceStart; +} + +/** + * Service that manages various aspects of the anonymous access. + */ +export class AnonymousAccessService { + constructor(private readonly logger: Logger, private readonly getConfig: () => ConfigType) {} + + setup(): AnonymousAccessServiceSetup { + return { + isAnonymousAccessEnabled: this.isAnonymousAccessEnabled(), + }; + } + + start({ + authz, + basePath, + 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, + isSavedObjectTypeAccessibleAnonymously: async (request, savedObjectType) => { + this.logger.debug(`Checking if Saved Object is accessible anonymously.`); + + // We should use credentials of the anonymous service account instead of credentials of the + // current user to figure out if the specified Saved Object type can be accessed anonymously. + const anonymousAuthorizationHeader = this.createAnonymousAuthorizationHeader(); + const fakeAnonymousRequest = KibanaRequest.from(({ + headers: anonymousAuthorizationHeader + ? { authorization: anonymousAuthorizationHeader.toString() } + : {}, + path: '/', + route: { settings: {} }, + url: { href: '/' }, + raw: { req: { url: '/' } }, + } as unknown) as Request); + + const spaceId = spaces?.getSpaceId(request); + if (spaceId) { + basePath.set(fakeAnonymousRequest, addSpaceIdToPath('/', spaceId)); + } + + const checkPrivileges = authz.checkSavedObjectsPrivilegesWithRequest(fakeAnonymousRequest); + const { hasAllRequested } = await checkPrivileges( + authz.actions.savedObject.get(savedObjectType, 'get'), + spaces && spaceId ? spaces.spaceIdToNamespace(spaceId) : undefined + ); + + if (hasAllRequested) { + this.logger.debug( + `Saved Objects with "${savedObjectType}" type can be accessed anonymously.` + ); + } else { + this.logger.debug( + `Saved Objects with "${savedObjectType}" type cannot be accessed anonymously.` + ); + } + + return hasAllRequested; + }, + }; + } + + /** + * Checks whether anonymous access is enabled. + * @private + */ + private isAnonymousAccessEnabled() { + return this.getConfig().authc.sortedProviders.some( + ({ type }) => type === AnonymousAuthenticationProvider.type + ); + } + + /** + * Creates authorization header that is used to authentication anonymous users. + * @private + */ + private createAnonymousAuthorizationHeader() { + const config = this.getConfig(); + const anonymousProvider = config.authc.sortedProviders.find( + ({ type }) => type === AnonymousAuthenticationProvider.type + ); + + return anonymousProvider + ? AnonymousAuthenticationProvider.createHTTPAuthorizationHeader( + (config.authc.providers.anonymous![anonymousProvider.name] as Record) + .credentials + ) + : null; + } +} 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..d7d6bd4300e08 --- /dev/null +++ b/x-pack/plugins/security/server/anonymous_access/index.ts @@ -0,0 +1,11 @@ +/* + * 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, + AnonymousAccessServiceSetup, +} 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 b43ffd86ae5ed..c761d80a0d629 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.ts b/x-pack/plugins/security/server/errors.ts index 9c177c3916faf..cf43782ca3dc3 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 { ResponseError as ESResponseError } from '@elastic/elasticsearch/lib/errors'; +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 ESResponseError) { + 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 ESResponseError) { + 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.ts b/x-pack/plugins/security/server/plugin.ts index 070e187e869b1..1d518bf6ed72c 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$, }); @@ -192,7 +218,7 @@ export class Plugin { config, clusterClient, http: core.http, - kibanaIndexName: legacyConfig.kibana.index, + kibanaIndexName, taskManager, }); @@ -219,6 +245,8 @@ export class Plugin { session, }); + const anonymousAccessSetup = this.anonymousAccessService.setup(); + this.authorizationSetup = this.authorizationService.setup({ http: core.http, capabilities: core.capabilities, @@ -226,7 +254,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, @@ -267,8 +295,19 @@ export class Plugin { return this.authenticationStart; }, + getAnonymousAccessService: () => { + if (!this.anonymousAccessStart) { + throw new Error('AnonymousAccess service is not started!'); + } + + return this.anonymousAccessStart; + }, }); + core.capabilities.registerProvider(() => ({ + security: { anonymousAccess: anonymousAccessSetup.isAnonymousAccessEnabled }, + })); + return Object.freeze({ audit: { asScoped: audit.asScoped, @@ -289,7 +328,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({ @@ -307,6 +349,12 @@ export class Plugin { this.authorizationService.start({ features, clusterClient, online$: watchOnlineStatus$() }); + this.anonymousAccessStart = this.anonymousAccessService.start({ + authz: this.authorizationSetup!, + basePath: core.http.basePath, + spaces: spaces?.spacesService, + }); + return Object.freeze({ authc: { apiKeys: this.authenticationStart.apiKeys, @@ -325,15 +373,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/plugins/security/server/routes/anonymous_access/can_access_saved_object_type.test.ts b/x-pack/plugins/security/server/routes/anonymous_access/can_access_saved_object_type.test.ts new file mode 100644 index 0000000000000..a104336f2e6ba --- /dev/null +++ b/x-pack/plugins/security/server/routes/anonymous_access/can_access_saved_object_type.test.ts @@ -0,0 +1,105 @@ +/* + * 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 { + IRouter, + kibanaResponseFactory, + RequestHandler, + RequestHandlerContext, + RouteConfig, +} from '../../../../../../src/core/server'; +import type { PublicMethodsOf } from '@kbn/utility-types'; +import { Session } from '../../session_management'; +import { defineSessionInfoRoutes } from './info'; + +import { httpServerMock } from '../../../../../../src/core/server/mocks'; +import { sessionMock } from '../../session_management/session.mock'; +import { routeDefinitionParamsMock } from '../index.mock'; + +describe('Info session routes', () => { + let router: jest.Mocked; + let session: jest.Mocked>; + beforeEach(() => { + const routeParamsMock = routeDefinitionParamsMock.create(); + router = routeParamsMock.router; + session = routeParamsMock.session; + + defineSessionInfoRoutes(routeParamsMock); + }); + + describe('extend session', () => { + let routeHandler: RequestHandler; + let routeConfig: RouteConfig; + beforeEach(() => { + const [extendRouteConfig, extendRouteHandler] = router.get.mock.calls.find( + ([{ path }]) => path === '/internal/security/session' + )!; + + routeConfig = extendRouteConfig; + routeHandler = extendRouteHandler; + }); + + it('correctly defines route.', () => { + expect(routeConfig.options).toBeUndefined(); + expect(routeConfig.validate).toBe(false); + }); + + it('returns 500 if unhandled exception is thrown when session is retrieved.', async () => { + const unhandledException = new Error('Something went wrong.'); + session.get.mockRejectedValue(unhandledException); + + const request = httpServerMock.createKibanaRequest(); + await expect( + routeHandler(({} as unknown) as RequestHandlerContext, request, kibanaResponseFactory) + ).resolves.toEqual({ + status: 500, + options: {}, + payload: 'Internal Error', + }); + + expect(session.get).toHaveBeenCalledWith(request); + }); + + it('returns session info.', async () => { + session.get.mockResolvedValue( + sessionMock.createValue({ idleTimeoutExpiration: 100, lifespanExpiration: 200 }) + ); + + const dateSpy = jest.spyOn(Date, 'now'); + dateSpy.mockReturnValue(1234); + + const expectedBody = { + now: 1234, + provider: { type: 'basic', name: 'basic1' }, + idleTimeoutExpiration: 100, + lifespanExpiration: 200, + }; + await expect( + routeHandler( + ({} as unknown) as RequestHandlerContext, + httpServerMock.createKibanaRequest(), + kibanaResponseFactory + ) + ).resolves.toEqual({ + status: 200, + payload: expectedBody, + options: { body: expectedBody }, + }); + }); + + it('returns empty response if session is not available.', async () => { + session.get.mockResolvedValue(null); + + await expect( + routeHandler( + ({} as unknown) as RequestHandlerContext, + httpServerMock.createKibanaRequest(), + kibanaResponseFactory + ) + ).resolves.toEqual({ status: 204, options: {} }); + }); + }); +}); diff --git a/x-pack/plugins/security/server/routes/anonymous_access/can_access_saved_object_type.ts b/x-pack/plugins/security/server/routes/anonymous_access/can_access_saved_object_type.ts new file mode 100644 index 0000000000000..1c1ea3634b768 --- /dev/null +++ b/x-pack/plugins/security/server/routes/anonymous_access/can_access_saved_object_type.ts @@ -0,0 +1,56 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { getDetailedErrorMessage, getErrorStatusCode } from '../../errors'; +import type { RouteDefinitionParams } from '..'; + +/** + * Defines routes that verify of specified Saved Object type can be accessed anonymously. + */ +export function defineCanAccessSavedObjectTypeRoutes({ + router, + logger, + getAnonymousAccessService, +}: RouteDefinitionParams) { + router.get( + { + path: '/internal/security/anonymous_access/_can_access_saved_object_type', + validate: { query: schema.object({ type: schema.string() }) }, + }, + async (_context, request, response) => { + const anonymousAccessService = getAnonymousAccessService(); + if (!anonymousAccessService.isAnonymousAccessEnabled) { + return response.ok({ body: { canAccess: false, accessURLParameters: null } }); + } + + try { + return response.ok({ + body: { + canAccess: await anonymousAccessService.isSavedObjectTypeAccessibleAnonymously( + request, + request.query.type + ), + accessURLParameters: anonymousAccessService.accessURLParameters + ? Object.fromEntries(anonymousAccessService.accessURLParameters.entries()) + : anonymousAccessService.accessURLParameters, + }, + }); + } catch (err) { + const errorMessage = getDetailedErrorMessage(err); + if (getErrorStatusCode(err) === 401) { + logger.error(`Anonymous access may not be properly configured yet: ${errorMessage}`); + return response.ok({ body: { canAccess: false, accessURLParameters: null } }); + } + + logger.error( + `Unexpected error occurred while checking if Saved Object type can be accessed anonymously: ${errorMessage}` + ); + return response.internalError(); + } + } + ); +} diff --git a/x-pack/plugins/security/server/routes/anonymous_access/index.ts b/x-pack/plugins/security/server/routes/anonymous_access/index.ts new file mode 100644 index 0000000000000..687d74d6491b3 --- /dev/null +++ b/x-pack/plugins/security/server/routes/anonymous_access/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { defineCanAccessSavedObjectTypeRoutes } from './can_access_saved_object_type'; +import { RouteDefinitionParams } from '..'; + +export function defineAnonymousAccessRoutes(params: RouteDefinitionParams) { + defineCanAccessSavedObjectTypeRoutes(params); +} diff --git a/x-pack/plugins/security/server/routes/index.mock.ts b/x-pack/plugins/security/server/routes/index.mock.ts index 4103594faba15..d4406fc86d3e3 100644 --- a/x-pack/plugins/security/server/routes/index.mock.ts +++ b/x-pack/plugins/security/server/routes/index.mock.ts @@ -14,6 +14,7 @@ import { authorizationMock } from '../authorization/index.mock'; import { ConfigSchema, createConfig } from '../config'; import { licenseMock } from '../../common/licensing/index.mock'; import { authenticationServiceMock } from '../authentication/authentication_service.mock'; +import { anonymousAccessServiceMock } from '../anonymous_access/anonymous_access_service.mock'; import { sessionMock } from '../session_management/session.mock'; import type { RouteDefinitionParams } from '.'; @@ -34,5 +35,8 @@ export const routeDefinitionParamsMock = { getFeatureUsageService: jest.fn(), session: sessionMock.create(), getAuthenticationService: jest.fn().mockReturnValue(authenticationServiceMock.createStart()), + getAnonymousAccessService: jest + .fn() + .mockReturnValue(anonymousAccessServiceMock.createStart()), } as unknown) as DeeplyMockedKeys), }; diff --git a/x-pack/plugins/security/server/routes/index.ts b/x-pack/plugins/security/server/routes/index.ts index 899215c49fa9f..ea12867fd3ddd 100644 --- a/x-pack/plugins/security/server/routes/index.ts +++ b/x-pack/plugins/security/server/routes/index.ts @@ -9,12 +9,14 @@ import type { HttpResources, IBasePath, IRouter, Logger } from '../../../../../s import type { SecurityLicense } from '../../common/licensing'; import type { AuthenticationServiceStart } from '../authentication'; import type { AuthorizationServiceSetup } from '../authorization'; +import type { AnonymousAccessServiceStart } from '../anonymous_access'; import type { ConfigType } from '../config'; import type { SecurityFeatureUsageServiceStart } from '../feature_usage'; import type { Session } from '../session_management'; import { defineAuthenticationRoutes } from './authentication'; import { defineAuthorizationRoutes } from './authorization'; +import { defineAnonymousAccessRoutes } from './anonymous_access'; import { defineApiKeysRoutes } from './api_keys'; import { defineIndicesRoutes } from './indices'; import { defineUsersRoutes } from './users'; @@ -37,11 +39,13 @@ export interface RouteDefinitionParams { getFeatures: () => Promise; getFeatureUsageService: () => SecurityFeatureUsageServiceStart; getAuthenticationService: () => AuthenticationServiceStart; + getAnonymousAccessService: () => AnonymousAccessServiceStart; } export function defineRoutes(params: RouteDefinitionParams) { defineAuthenticationRoutes(params); defineAuthorizationRoutes(params); + defineAnonymousAccessRoutes(params); defineSessionManagementRoutes(params); defineApiKeysRoutes(params); defineIndicesRoutes(params); From 4a77cfb92a3f0eafdba4e71aa68ec7c62a53d291 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Tue, 5 Jan 2021 14:29:01 +0100 Subject: [PATCH 2/7] Review#1: invert implicit OSS plugin dependency on X-Pack. --- src/plugins/security_oss/public/plugin.ts | 2 +- src/plugins/security_oss/server/plugin.ts | 56 +++++++++- .../routes/can_access_saved_object_type.ts | 57 ++++++++++ .../security_oss/server/routes/index.ts | 1 + .../anonymous_access_service.ts | 29 ++--- x-pack/plugins/security/server/plugin.ts | 14 +-- .../can_access_saved_object_type.test.ts | 105 ------------------ .../can_access_saved_object_type.ts | 56 ---------- .../server/routes/anonymous_access/index.ts | 12 -- .../security/server/routes/index.mock.ts | 4 - .../plugins/security/server/routes/index.ts | 4 - 11 files changed, 135 insertions(+), 205 deletions(-) create mode 100644 src/plugins/security_oss/server/routes/can_access_saved_object_type.ts delete mode 100644 x-pack/plugins/security/server/routes/anonymous_access/can_access_saved_object_type.test.ts delete mode 100644 x-pack/plugins/security/server/routes/anonymous_access/can_access_saved_object_type.ts delete mode 100644 x-pack/plugins/security/server/routes/anonymous_access/index.ts diff --git a/src/plugins/security_oss/public/plugin.ts b/src/plugins/security_oss/public/plugin.ts index 74e2b40b060e7..2682ac0d9acfa 100644 --- a/src/plugins/security_oss/public/plugin.ts +++ b/src/plugins/security_oss/public/plugin.ts @@ -75,7 +75,7 @@ export class SecurityOssPlugin async canAccessSavedObjectType(savedObjectType: string) { if (isAnonymousAccessEnabled) { return await core.http.get( - `/internal/security/anonymous_access/_can_access_saved_object_type?type=${encodeURIComponent( + `/internal/security_oss/anonymous_access/_can_access_saved_object_type?type=${encodeURIComponent( savedObjectType )}` ); diff --git a/src/plugins/security_oss/server/plugin.ts b/src/plugins/security_oss/server/plugin.ts index e48827f21a13a..d352ef60a4f91 100644 --- a/src/plugins/security_oss/server/plugin.ts +++ b/src/plugins/security_oss/server/plugin.ts @@ -17,22 +17,56 @@ * under the License. */ -import { CoreSetup, Logger, Plugin, PluginInitializerContext } from 'kibana/server'; +import { 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 { + setupDisplayInsecureClusterAlertRoute, + setupCanAccessSavedObjectTypeRoute, +} from './routes'; export interface SecurityOssPluginSetup { /** * Allows consumers to show/hide the insecure cluster warning. */ showInsecureClusterWarning$: BehaviorSubject; + + /** + * Set the provider function that returns a service that can deal with various aspects of 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 URL so that anonymous user can use + * to automatically log in to Kibana and access particular Saved Object type. + */ + readonly accessURLParameters: Readonly> | null; + + /** + * Checks if the specified Saved Object type is accessible + * @param request Kibana request instance. + * @param savedObjectType Saved Object type to check access for. + */ + isSavedObjectTypeAccessibleAnonymously: ( + request: KibanaRequest, + savedObjectType: string + ) => 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(); @@ -51,8 +85,26 @@ export class SecurityOssPlugin implements Plugin { + if (!this.anonymousAccessServiceProvider) { + throw new Error('Anonymous Access service provider is not set.'); + } + + return this.anonymousAccessServiceProvider(); + }, + }); + 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/can_access_saved_object_type.ts b/src/plugins/security_oss/server/routes/can_access_saved_object_type.ts new file mode 100644 index 0000000000000..6772aba098a46 --- /dev/null +++ b/src/plugins/security_oss/server/routes/can_access_saved_object_type.ts @@ -0,0 +1,57 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { IRouter } from 'kibana/server'; +import { AnonymousAccessService } from '../plugin'; + +interface Deps { + router: IRouter; + getAnonymousAccessService: () => AnonymousAccessService; +} + +/** + * Defines route that checks if the specified Saved Object type can be accessed anonymously. + */ +export function setupCanAccessSavedObjectTypeRoute({ router, getAnonymousAccessService }: Deps) { + router.get( + { + path: '/internal/security_oss/anonymous_access/_can_access_saved_object_type', + validate: { query: schema.object({ type: schema.string() }) }, + }, + async (_context, request, response) => { + const anonymousAccessService = getAnonymousAccessService(); + if (!anonymousAccessService.isAnonymousAccessEnabled) { + return response.ok({ body: { canAccess: false, accessURLParameters: null } }); + } + + return response.ok({ + body: { + canAccess: await anonymousAccessService.isSavedObjectTypeAccessibleAnonymously( + request, + request.query.type + ), + accessURLParameters: anonymousAccessService.accessURLParameters + ? Object.fromEntries(anonymousAccessService.accessURLParameters.entries()) + : anonymousAccessService.accessURLParameters, + }, + }); + } + ); +} diff --git a/src/plugins/security_oss/server/routes/index.ts b/src/plugins/security_oss/server/routes/index.ts index ceff0b12c9cb1..1f48c4725132a 100644 --- a/src/plugins/security_oss/server/routes/index.ts +++ b/src/plugins/security_oss/server/routes/index.ts @@ -18,3 +18,4 @@ */ export { setupDisplayInsecureClusterAlertRoute } from './display_insecure_cluster_alert'; +export { setupCanAccessSavedObjectTypeRoute } from './can_access_saved_object_type'; 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 index 7952f40d52025..819e040408f42 100644 --- a/x-pack/plugins/security/server/anonymous_access/anonymous_access_service.ts +++ b/x-pack/plugins/security/server/anonymous_access/anonymous_access_service.ts @@ -12,6 +12,7 @@ import { AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER } from '../../common/constant import { AnonymousAuthenticationProvider } from '../authentication'; import type { AuthorizationServiceSetup } from '../authorization'; import type { ConfigType } from '../config'; +import { getDetailedErrorMessage, getErrorStatusCode } from '../errors'; export interface AnonymousAccessServiceSetup { readonly isAnonymousAccessEnabled: boolean; @@ -90,22 +91,22 @@ export class AnonymousAccessService { } const checkPrivileges = authz.checkSavedObjectsPrivilegesWithRequest(fakeAnonymousRequest); - const { hasAllRequested } = await checkPrivileges( - authz.actions.savedObject.get(savedObjectType, 'get'), - spaces && spaceId ? spaces.spaceIdToNamespace(spaceId) : undefined - ); - - if (hasAllRequested) { - this.logger.debug( - `Saved Objects with "${savedObjectType}" type can be accessed anonymously.` - ); - } else { - this.logger.debug( - `Saved Objects with "${savedObjectType}" type cannot be accessed anonymously.` + try { + const { hasAllRequested } = await checkPrivileges( + authz.actions.savedObject.get(savedObjectType, 'get'), + spaces && spaceId ? spaces.spaceIdToNamespace(spaceId) : undefined ); + return hasAllRequested; + } catch (err) { + if (getErrorStatusCode(err) === 401) { + this.logger.warn( + `Anonymous access may not be properly configured: ${getDetailedErrorMessage(err)}` + ); + return false; + } + + throw err; } - - return hasAllRequested; }, }; } diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index 1d518bf6ed72c..2a5aacfb87d6c 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -198,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) => @@ -295,13 +302,6 @@ export class Plugin { return this.authenticationStart; }, - getAnonymousAccessService: () => { - if (!this.anonymousAccessStart) { - throw new Error('AnonymousAccess service is not started!'); - } - - return this.anonymousAccessStart; - }, }); core.capabilities.registerProvider(() => ({ diff --git a/x-pack/plugins/security/server/routes/anonymous_access/can_access_saved_object_type.test.ts b/x-pack/plugins/security/server/routes/anonymous_access/can_access_saved_object_type.test.ts deleted file mode 100644 index a104336f2e6ba..0000000000000 --- a/x-pack/plugins/security/server/routes/anonymous_access/can_access_saved_object_type.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { - IRouter, - kibanaResponseFactory, - RequestHandler, - RequestHandlerContext, - RouteConfig, -} from '../../../../../../src/core/server'; -import type { PublicMethodsOf } from '@kbn/utility-types'; -import { Session } from '../../session_management'; -import { defineSessionInfoRoutes } from './info'; - -import { httpServerMock } from '../../../../../../src/core/server/mocks'; -import { sessionMock } from '../../session_management/session.mock'; -import { routeDefinitionParamsMock } from '../index.mock'; - -describe('Info session routes', () => { - let router: jest.Mocked; - let session: jest.Mocked>; - beforeEach(() => { - const routeParamsMock = routeDefinitionParamsMock.create(); - router = routeParamsMock.router; - session = routeParamsMock.session; - - defineSessionInfoRoutes(routeParamsMock); - }); - - describe('extend session', () => { - let routeHandler: RequestHandler; - let routeConfig: RouteConfig; - beforeEach(() => { - const [extendRouteConfig, extendRouteHandler] = router.get.mock.calls.find( - ([{ path }]) => path === '/internal/security/session' - )!; - - routeConfig = extendRouteConfig; - routeHandler = extendRouteHandler; - }); - - it('correctly defines route.', () => { - expect(routeConfig.options).toBeUndefined(); - expect(routeConfig.validate).toBe(false); - }); - - it('returns 500 if unhandled exception is thrown when session is retrieved.', async () => { - const unhandledException = new Error('Something went wrong.'); - session.get.mockRejectedValue(unhandledException); - - const request = httpServerMock.createKibanaRequest(); - await expect( - routeHandler(({} as unknown) as RequestHandlerContext, request, kibanaResponseFactory) - ).resolves.toEqual({ - status: 500, - options: {}, - payload: 'Internal Error', - }); - - expect(session.get).toHaveBeenCalledWith(request); - }); - - it('returns session info.', async () => { - session.get.mockResolvedValue( - sessionMock.createValue({ idleTimeoutExpiration: 100, lifespanExpiration: 200 }) - ); - - const dateSpy = jest.spyOn(Date, 'now'); - dateSpy.mockReturnValue(1234); - - const expectedBody = { - now: 1234, - provider: { type: 'basic', name: 'basic1' }, - idleTimeoutExpiration: 100, - lifespanExpiration: 200, - }; - await expect( - routeHandler( - ({} as unknown) as RequestHandlerContext, - httpServerMock.createKibanaRequest(), - kibanaResponseFactory - ) - ).resolves.toEqual({ - status: 200, - payload: expectedBody, - options: { body: expectedBody }, - }); - }); - - it('returns empty response if session is not available.', async () => { - session.get.mockResolvedValue(null); - - await expect( - routeHandler( - ({} as unknown) as RequestHandlerContext, - httpServerMock.createKibanaRequest(), - kibanaResponseFactory - ) - ).resolves.toEqual({ status: 204, options: {} }); - }); - }); -}); diff --git a/x-pack/plugins/security/server/routes/anonymous_access/can_access_saved_object_type.ts b/x-pack/plugins/security/server/routes/anonymous_access/can_access_saved_object_type.ts deleted file mode 100644 index 1c1ea3634b768..0000000000000 --- a/x-pack/plugins/security/server/routes/anonymous_access/can_access_saved_object_type.ts +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { schema } from '@kbn/config-schema'; -import { getDetailedErrorMessage, getErrorStatusCode } from '../../errors'; -import type { RouteDefinitionParams } from '..'; - -/** - * Defines routes that verify of specified Saved Object type can be accessed anonymously. - */ -export function defineCanAccessSavedObjectTypeRoutes({ - router, - logger, - getAnonymousAccessService, -}: RouteDefinitionParams) { - router.get( - { - path: '/internal/security/anonymous_access/_can_access_saved_object_type', - validate: { query: schema.object({ type: schema.string() }) }, - }, - async (_context, request, response) => { - const anonymousAccessService = getAnonymousAccessService(); - if (!anonymousAccessService.isAnonymousAccessEnabled) { - return response.ok({ body: { canAccess: false, accessURLParameters: null } }); - } - - try { - return response.ok({ - body: { - canAccess: await anonymousAccessService.isSavedObjectTypeAccessibleAnonymously( - request, - request.query.type - ), - accessURLParameters: anonymousAccessService.accessURLParameters - ? Object.fromEntries(anonymousAccessService.accessURLParameters.entries()) - : anonymousAccessService.accessURLParameters, - }, - }); - } catch (err) { - const errorMessage = getDetailedErrorMessage(err); - if (getErrorStatusCode(err) === 401) { - logger.error(`Anonymous access may not be properly configured yet: ${errorMessage}`); - return response.ok({ body: { canAccess: false, accessURLParameters: null } }); - } - - logger.error( - `Unexpected error occurred while checking if Saved Object type can be accessed anonymously: ${errorMessage}` - ); - return response.internalError(); - } - } - ); -} diff --git a/x-pack/plugins/security/server/routes/anonymous_access/index.ts b/x-pack/plugins/security/server/routes/anonymous_access/index.ts deleted file mode 100644 index 687d74d6491b3..0000000000000 --- a/x-pack/plugins/security/server/routes/anonymous_access/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { defineCanAccessSavedObjectTypeRoutes } from './can_access_saved_object_type'; -import { RouteDefinitionParams } from '..'; - -export function defineAnonymousAccessRoutes(params: RouteDefinitionParams) { - defineCanAccessSavedObjectTypeRoutes(params); -} diff --git a/x-pack/plugins/security/server/routes/index.mock.ts b/x-pack/plugins/security/server/routes/index.mock.ts index d4406fc86d3e3..4103594faba15 100644 --- a/x-pack/plugins/security/server/routes/index.mock.ts +++ b/x-pack/plugins/security/server/routes/index.mock.ts @@ -14,7 +14,6 @@ import { authorizationMock } from '../authorization/index.mock'; import { ConfigSchema, createConfig } from '../config'; import { licenseMock } from '../../common/licensing/index.mock'; import { authenticationServiceMock } from '../authentication/authentication_service.mock'; -import { anonymousAccessServiceMock } from '../anonymous_access/anonymous_access_service.mock'; import { sessionMock } from '../session_management/session.mock'; import type { RouteDefinitionParams } from '.'; @@ -35,8 +34,5 @@ export const routeDefinitionParamsMock = { getFeatureUsageService: jest.fn(), session: sessionMock.create(), getAuthenticationService: jest.fn().mockReturnValue(authenticationServiceMock.createStart()), - getAnonymousAccessService: jest - .fn() - .mockReturnValue(anonymousAccessServiceMock.createStart()), } as unknown) as DeeplyMockedKeys), }; diff --git a/x-pack/plugins/security/server/routes/index.ts b/x-pack/plugins/security/server/routes/index.ts index ea12867fd3ddd..899215c49fa9f 100644 --- a/x-pack/plugins/security/server/routes/index.ts +++ b/x-pack/plugins/security/server/routes/index.ts @@ -9,14 +9,12 @@ import type { HttpResources, IBasePath, IRouter, Logger } from '../../../../../s import type { SecurityLicense } from '../../common/licensing'; import type { AuthenticationServiceStart } from '../authentication'; import type { AuthorizationServiceSetup } from '../authorization'; -import type { AnonymousAccessServiceStart } from '../anonymous_access'; import type { ConfigType } from '../config'; import type { SecurityFeatureUsageServiceStart } from '../feature_usage'; import type { Session } from '../session_management'; import { defineAuthenticationRoutes } from './authentication'; import { defineAuthorizationRoutes } from './authorization'; -import { defineAnonymousAccessRoutes } from './anonymous_access'; import { defineApiKeysRoutes } from './api_keys'; import { defineIndicesRoutes } from './indices'; import { defineUsersRoutes } from './users'; @@ -39,13 +37,11 @@ export interface RouteDefinitionParams { getFeatures: () => Promise; getFeatureUsageService: () => SecurityFeatureUsageServiceStart; getAuthenticationService: () => AuthenticationServiceStart; - getAnonymousAccessService: () => AnonymousAccessServiceStart; } export function defineRoutes(params: RouteDefinitionParams) { defineAuthenticationRoutes(params); defineAuthorizationRoutes(params); - defineAnonymousAccessRoutes(params); defineSessionManagementRoutes(params); defineApiKeysRoutes(params); defineIndicesRoutes(params); From 0fad3e6463274775138e25e2bf7258957746f617 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Wed, 6 Jan 2021 12:33:16 +0100 Subject: [PATCH 3/7] Review#1: use capabilities API to check if anonymous service account has enough privileges. --- src/plugins/security_oss/common/app_state.ts | 29 +++++++ src/plugins/security_oss/common/index.ts | 20 +++++ .../public/app_state/app_state_service.ts | 47 +++++++++++ .../security_oss/public/app_state/index.ts | 20 +++++ .../insecure_cluster_service.tsx | 29 +++---- src/plugins/security_oss/public/plugin.ts | 39 +++++---- src/plugins/security_oss/server/plugin.ts | 44 ++++++----- ...pe.ts => anonymous_access_capabilities.ts} | 25 +----- ...insecure_cluster_alert.ts => app_state.ts} | 37 ++++++--- .../security_oss/server/routes/index.ts | 4 +- ...luster_alert.test.ts => app_state.test.ts} | 2 +- src/plugins/security_oss/tsconfig.json | 2 +- .../anonymous_access_service.mock.ts | 4 +- .../anonymous_access_service.ts | 79 +++++++++---------- x-pack/plugins/security/server/plugin.ts | 8 +- 15 files changed, 249 insertions(+), 140 deletions(-) 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.ts create mode 100644 src/plugins/security_oss/public/app_state/index.ts rename src/plugins/security_oss/server/routes/{can_access_saved_object_type.ts => anonymous_access_capabilities.ts} (51%) rename src/plugins/security_oss/server/routes/{display_insecure_cluster_alert.ts => app_state.ts} (59%) rename src/plugins/security_oss/server/routes/integration_tests/{display_insecure_cluster_alert.test.ts => app_state.test.ts} (98%) 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.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.tsx b/src/plugins/security_oss/public/insecure_cluster_service/insecure_cluster_service.tsx index e6255233354b7..0b4d7216bc443 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 @@ -22,6 +22,7 @@ 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 { AppStateServiceStart } from '../app_state'; import { defaultAlertText, defaultAlertTitle } from './components'; interface SetupDeps { @@ -30,6 +31,7 @@ interface SetupDeps { interface StartDeps { 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 2682ac0d9acfa..9750154d21286 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 { + Capabilities, + CoreSetup, + CoreStart, + Plugin, + PluginInitializerContext, +} from 'src/core/public'; import { ConfigType } from './config'; import { InsecureClusterService, InsecureClusterServiceSetup, InsecureClusterServiceStart, } from './insecure_cluster_service'; +import { AppStateService } from './app_state'; export interface SavedObjectTypeAnonymousAccess { /** @@ -44,8 +51,8 @@ export interface SecurityOssPluginSetup { export interface SecurityOssPluginStart { insecureCluster: InsecureClusterServiceStart; anonymousAccess: { - isAnonymousAccessEnabled: boolean; - canAccessSavedObjectType: (savedObjectType: string) => Promise; + getAccessURLParameters: () => Promise | null>; + getCapabilities: () => Promise; }; } @@ -53,7 +60,8 @@ export class SecurityOssPlugin implements Plugin { private readonly config: ConfigType; - private insecureClusterService: InsecureClusterService; + private readonly insecureClusterService: InsecureClusterService; + private readonly appStateService = new AppStateService(); constructor(private readonly initializerContext: PluginInitializerContext) { this.config = this.initializerContext.config.get(); @@ -67,21 +75,18 @@ export class SecurityOssPlugin } public start(core: CoreStart) { - const isAnonymousAccessEnabled = !!core.application.capabilities.security?.anonymousAccess; + const appState = this.appStateService.start({ core }); return { - insecureCluster: this.insecureClusterService.start({ core }), + insecureCluster: this.insecureClusterService.start({ core, appState }), anonymousAccess: { - isAnonymousAccessEnabled, - async canAccessSavedObjectType(savedObjectType: string) { - if (isAnonymousAccessEnabled) { - return await core.http.get( - `/internal/security_oss/anonymous_access/_can_access_saved_object_type?type=${encodeURIComponent( - savedObjectType - )}` - ); - } - - return { canAccess: false, accessURLParameters: null }; + async getAccessURLParameters() { + const { anonymousAccess } = await appState.getState(); + return anonymousAccess.accessURLParameters; + }, + async getCapabilities() { + return await core.http.get( + '/internal/security_oss/anonymous_access/capabilities' + ); }, }, }; diff --git a/src/plugins/security_oss/server/plugin.ts b/src/plugins/security_oss/server/plugin.ts index d352ef60a4f91..40ac0b2794da0 100644 --- a/src/plugins/security_oss/server/plugin.ts +++ b/src/plugins/security_oss/server/plugin.ts @@ -17,14 +17,18 @@ * under the License. */ -import { CoreSetup, KibanaRequest, Logger, Plugin, PluginInitializerContext } from 'kibana/server'; +import { + 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, - setupCanAccessSavedObjectTypeRoute, -} from './routes'; +import { setupAppStateRoute, setupAnonymousAccessCapabilitiesRoute } from './routes'; export interface SecurityOssPluginSetup { /** @@ -48,25 +52,28 @@ export interface AnonymousAccessService { /** * A map of query string parameters that should be specified in URL so that anonymous user can use - * to automatically log in to Kibana and access particular Saved Object type. + * to automatically log in to Kibana. */ readonly accessURLParameters: Readonly> | null; /** - * Checks if the specified Saved Object type is accessible + * Gets capabilities of the anonymous service account. * @param request Kibana request instance. - * @param savedObjectType Saved Object type to check access for. */ - isSavedObjectTypeAccessibleAnonymously: ( - request: KibanaRequest, - savedObjectType: string - ) => Promise; + getCapabilities: (request: KibanaRequest) => Promise; } export class SecurityOssPlugin implements Plugin { private readonly config$: Observable; private readonly logger: Logger; + private anonymousAccessServiceProvider?: () => AnonymousAccessService; + private readonly getAnonymousAccessService = () => { + if (!this.anonymousAccessServiceProvider) { + throw new Error('Anonymous Access service provider is not set.'); + } + return this.anonymousAccessServiceProvider(); + }; constructor(initializerContext: PluginInitializerContext) { this.config$ = initializerContext.config.create(); @@ -77,23 +84,18 @@ export class SecurityOssPlugin implements Plugin(true); - setupDisplayInsecureClusterAlertRoute({ + setupAppStateRoute({ router, log: this.logger, config$: this.config$, displayModifier$: showInsecureClusterWarning$, doesClusterHaveUserData: createClusterDataCheck(), + getAnonymousAccessService: this.getAnonymousAccessService, }); - setupCanAccessSavedObjectTypeRoute({ + setupAnonymousAccessCapabilitiesRoute({ router, - getAnonymousAccessService: () => { - if (!this.anonymousAccessServiceProvider) { - throw new Error('Anonymous Access service provider is not set.'); - } - - return this.anonymousAccessServiceProvider(); - }, + getAnonymousAccessService: this.getAnonymousAccessService, }); return { diff --git a/src/plugins/security_oss/server/routes/can_access_saved_object_type.ts b/src/plugins/security_oss/server/routes/anonymous_access_capabilities.ts similarity index 51% rename from src/plugins/security_oss/server/routes/can_access_saved_object_type.ts rename to src/plugins/security_oss/server/routes/anonymous_access_capabilities.ts index 6772aba098a46..a1473cd9b6954 100644 --- a/src/plugins/security_oss/server/routes/can_access_saved_object_type.ts +++ b/src/plugins/security_oss/server/routes/anonymous_access_capabilities.ts @@ -17,7 +17,6 @@ * under the License. */ -import { schema } from '@kbn/config-schema'; import { IRouter } from 'kibana/server'; import { AnonymousAccessService } from '../plugin'; @@ -27,30 +26,14 @@ interface Deps { } /** - * Defines route that checks if the specified Saved Object type can be accessed anonymously. + * Defines route that returns capabilities of the anonymous service account. */ -export function setupCanAccessSavedObjectTypeRoute({ router, getAnonymousAccessService }: Deps) { +export function setupAnonymousAccessCapabilitiesRoute({ router, getAnonymousAccessService }: Deps) { router.get( - { - path: '/internal/security_oss/anonymous_access/_can_access_saved_object_type', - validate: { query: schema.object({ type: schema.string() }) }, - }, + { path: '/internal/security_oss/anonymous_access/capabilities', validate: false }, async (_context, request, response) => { - const anonymousAccessService = getAnonymousAccessService(); - if (!anonymousAccessService.isAnonymousAccessEnabled) { - return response.ok({ body: { canAccess: false, accessURLParameters: null } }); - } - return response.ok({ - body: { - canAccess: await anonymousAccessService.isSavedObjectTypeAccessibleAnonymously( - request, - request.query.type - ), - accessURLParameters: anonymousAccessService.accessURLParameters - ? Object.fromEntries(anonymousAccessService.accessURLParameters.entries()) - : anonymousAccessService.accessURLParameters, - }, + body: await getAnonymousAccessService().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 59% 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..68777bf1fd1b0 100644 --- a/src/plugins/security_oss/server/routes/display_insecure_cluster_alert.ts +++ b/src/plugins/security_oss/server/routes/app_state.ts @@ -19,8 +19,10 @@ import { 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 { AnonymousAccessService } from '../plugin'; interface Deps { router: IRouter; @@ -28,14 +30,16 @@ interface Deps { config$: Observable; displayModifier$: Observable; doesClusterHaveUserData: ReturnType; + getAnonymousAccessService: () => AnonymousAccessService; } -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, + accessURLParameters: anonymousAccessService.accessURLParameters + ? Object.fromEntries(anonymousAccessService.accessURLParameters.entries()) + : anonymousAccessService.accessURLParameters, + }, + }; + 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 1f48c4725132a..8b6dc6b6f217f 100644 --- a/src/plugins/security_oss/server/routes/index.ts +++ b/src/plugins/security_oss/server/routes/index.ts @@ -17,5 +17,5 @@ * under the License. */ -export { setupDisplayInsecureClusterAlertRoute } from './display_insecure_cluster_alert'; -export { setupCanAccessSavedObjectTypeRoute } from './can_access_saved_object_type'; +export { setupAppStateRoute } from './app_state'; +export { setupAnonymousAccessCapabilitiesRoute } from './anonymous_access_capabilities'; 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 98% 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..faae814303c27 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,7 @@ import { loggingSystemMock } from '../../../../../core/server/mocks'; import { setupServer } from '../../../../../core/server/test_utils'; -import { setupDisplayInsecureClusterAlertRoute } from '../display_insecure_cluster_alert'; +import { setupAppStateRoute } from '../app_state'; import { ConfigType } from '../../config'; import { BehaviorSubject, of } from 'rxjs'; import { UnwrapPromise } from '@kbn/utility-types'; 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 index c1f4b48e39743..99fd7414039c0 100644 --- 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 @@ -9,6 +9,8 @@ import type { AnonymousAccessServiceStart, } from './anonymous_access_service'; +import { capabilitiesServiceMock } from '../../../../../src/core/server/mocks'; + export const anonymousAccessServiceMock = { createSetup: (): jest.Mocked => ({ isAnonymousAccessEnabled: false, @@ -16,6 +18,6 @@ export const anonymousAccessServiceMock = { createStart: (): jest.Mocked => ({ isAnonymousAccessEnabled: false, accessURLParameters: null, - isSavedObjectTypeAccessibleAnonymously: jest.fn(), + getCapabilities: jest.fn().mockReturnValue(capabilitiesServiceMock.createCapabilities()), }), }; 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 index 819e040408f42..c057762ca3cb3 100644 --- a/x-pack/plugins/security/server/anonymous_access/anonymous_access_service.ts +++ b/x-pack/plugins/security/server/anonymous_access/anonymous_access_service.ts @@ -5,50 +5,55 @@ */ import type { Request } from '@hapi/hapi'; -import { IBasePath, KibanaRequest, Logger } from '../../../../../src/core/server'; +import { + CapabilitiesStart, + IBasePath, + KibanaRequest, + Logger, + Capabilities, +} 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 } from '../authentication'; -import type { AuthorizationServiceSetup } from '../authorization'; import type { ConfigType } from '../config'; import { getDetailedErrorMessage, getErrorStatusCode } from '../errors'; -export interface AnonymousAccessServiceSetup { - readonly isAnonymousAccessEnabled: boolean; -} - export interface AnonymousAccessServiceStart { readonly isAnonymousAccessEnabled: boolean; // We cannot use `ReadonlyMap` just yet: https://github.com/microsoft/TypeScript/issues/16840 readonly accessURLParameters: Readonly> | null; - isSavedObjectTypeAccessibleAnonymously: ( - request: KibanaRequest, - savedObjectType: string - ) => Promise; + getCapabilities: (request: KibanaRequest) => Promise; } interface AnonymousAccessServiceStartParams { - authz: Pick; basePath: IBasePath; + capabilities: CapabilitiesStart; spaces?: SpacesServiceStart; } +const DEFAULT_CAPABILITIES: Capabilities = { navLinks: {}, management: {}, catalogue: {} }; + /** * Service that manages various aspects of the anonymous access. */ export class AnonymousAccessService { + /** + * Indicates whether anonymous access is enabled. + */ + private isAnonymousAccessEnabled = false; + constructor(private readonly logger: Logger, private readonly getConfig: () => ConfigType) {} - setup(): AnonymousAccessServiceSetup { - return { - isAnonymousAccessEnabled: this.isAnonymousAccessEnabled(), - }; + setup() { + this.isAnonymousAccessEnabled = this.getConfig().authc.sortedProviders.some( + ({ type }) => type === AnonymousAuthenticationProvider.type + ); } start({ - authz, basePath, + capabilities, spaces, }: AnonymousAccessServiceStartParams): AnonymousAccessServiceStart { const config = this.getConfig(); @@ -62,15 +67,20 @@ export class AnonymousAccessService { !config.authc.selector.enabled && anonymousProvider === config.authc.sortedProviders[0]; return { - isAnonymousAccessEnabled: this.isAnonymousAccessEnabled(), + isAnonymousAccessEnabled: this.isAnonymousAccessEnabled, accessURLParameters: anonymousProvider && !anonymousIsDefault ? Object.freeze( new Map([[AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER, anonymousProvider.name]]) ) : null, - isSavedObjectTypeAccessibleAnonymously: async (request, savedObjectType) => { - this.logger.debug(`Checking if Saved Object is accessible anonymously.`); + getCapabilities: async (request) => { + this.logger.debug('Retrieving capabilities of the anonymous service account.'); + + if (!this.isAnonymousAccessEnabled) { + this.logger.warn('Anonymous access is not enabled'); + return DEFAULT_CAPABILITIES; + } // We should use credentials of the anonymous service account instead of credentials of the // current user to figure out if the specified Saved Object type can be accessed anonymously. @@ -79,6 +89,9 @@ export class AnonymousAccessService { headers: anonymousAuthorizationHeader ? { authorization: anonymousAuthorizationHeader.toString() } : {}, + // We should pretend that this request is authenticated to force authorization service to + // perform a privileges check and not to automatically disable all capabilities. + auth: { isAuthenticated: true }, path: '/', route: { settings: {} }, url: { href: '/' }, @@ -90,40 +103,26 @@ export class AnonymousAccessService { basePath.set(fakeAnonymousRequest, addSpaceIdToPath('/', spaceId)); } - const checkPrivileges = authz.checkSavedObjectsPrivilegesWithRequest(fakeAnonymousRequest); try { - const { hasAllRequested } = await checkPrivileges( - authz.actions.savedObject.get(savedObjectType, 'get'), - spaces && spaceId ? spaces.spaceIdToNamespace(spaceId) : undefined - ); - return hasAllRequested; + return await capabilities.resolveCapabilities(fakeAnonymousRequest); } catch (err) { + const errorMessage = getDetailedErrorMessage(err); if (getErrorStatusCode(err) === 401) { - this.logger.warn( - `Anonymous access may not be properly configured: ${getDetailedErrorMessage(err)}` - ); - return false; + this.logger.warn(`Cannot authenticate anonymous service account: ${errorMessage}`); + return DEFAULT_CAPABILITIES; } + this.logger.error( + `Failed to retrieve anonymous service account capabilities: ${errorMessage}` + ); throw err; } }, }; } - /** - * Checks whether anonymous access is enabled. - * @private - */ - private isAnonymousAccessEnabled() { - return this.getConfig().authc.sortedProviders.some( - ({ type }) => type === AnonymousAuthenticationProvider.type - ); - } - /** * Creates authorization header that is used to authentication anonymous users. - * @private */ private createAnonymousAuthorizationHeader() { const config = this.getConfig(); diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index 2a5aacfb87d6c..7a2e3016e6c5d 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -252,7 +252,7 @@ export class Plugin { session, }); - const anonymousAccessSetup = this.anonymousAccessService.setup(); + this.anonymousAccessService.setup(); this.authorizationSetup = this.authorizationService.setup({ http: core.http, @@ -304,10 +304,6 @@ export class Plugin { }, }); - core.capabilities.registerProvider(() => ({ - security: { anonymousAccess: anonymousAccessSetup.isAnonymousAccessEnabled }, - })); - return Object.freeze({ audit: { asScoped: audit.asScoped, @@ -350,7 +346,7 @@ export class Plugin { this.authorizationService.start({ features, clusterClient, online$: watchOnlineStatus$() }); this.anonymousAccessStart = this.anonymousAccessService.start({ - authz: this.authorizationSetup!, + capabilities: core.capabilities, basePath: core.http.basePath, spaces: spaces?.spacesService, }); From 2277675199c92fd8fe925219c0171139d6157339 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Thu, 7 Jan 2021 10:32:49 +0100 Subject: [PATCH 4/7] Review#2: fix and write tests, handle review feedback. --- ...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 +- .../app_state/app_state_service.mock.ts | 32 +++ .../app_state/app_state_service.test.ts | 72 +++++ .../insecure_cluster_service.test.tsx | 152 ++++++----- .../insecure_cluster_service.tsx | 2 +- src/plugins/security_oss/public/plugin.ts | 22 +- .../security_oss/server/plugin.test.ts | 1 + src/plugins/security_oss/server/plugin.ts | 11 +- .../routes/anonymous_access_capabilities.ts | 11 +- .../security_oss/server/routes/app_state.ts | 8 +- .../anonymous_access_capabilities.test.ts | 86 ++++++ .../integration_tests/app_state.test.ts | 95 +++++-- .../anonymous_access_service.mock.ts | 8 +- .../anonymous_access_service.test.ts | 255 ++++++++++++++++++ .../anonymous_access_service.ts | 118 +++++--- .../security/server/anonymous_access/index.ts | 6 +- x-pack/plugins/security/server/errors.test.ts | 72 ++++- x-pack/plugins/security/server/errors.ts | 6 +- x-pack/plugins/security/server/plugin.test.ts | 119 ++++---- x-pack/plugins/security/server/plugin.ts | 1 + 29 files changed, 949 insertions(+), 249 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/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/server/routes/integration_tests/anonymous_access_capabilities.test.ts create mode 100644 x-pack/plugins/security/server/anonymous_access/anonymous_access_service.test.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 269db90c4db9b..9409bfd16f136 100644 --- a/docs/development/core/server/kibana-plugin-core-server.md +++ b/docs/development/core/server/kibana-plugin-core-server.md @@ -132,6 +132,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..57f8247304703 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), }; } } 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 0f2761b67437d..7612483661b4d 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -85,7 +85,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 848cd3a657e9c..8732a4d3ce0ec 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -306,7 +306,7 @@ export interface CapabilitiesSetup { // @public export interface CapabilitiesStart { - resolveCapabilities(request: KibanaRequest): Promise; + resolveCapabilities(request: KibanaRequest, options?: ResolveCapabilitiesOptions): Promise; } // @public @@ -1916,6 +1916,11 @@ export type RequestHandlerContextProvider(handler: RequestHandler) => RequestHandler; +// @public +export interface ResolveCapabilitiesOptions { + useDefaultCapabilities: boolean; +} + // @public export function resolveSavedObjectsImportErrors({ readStream, objectLimit, retries, savedObjectsClient, typeRegistry, namespace, createNewCopies, }: SavedObjectsResolveImportErrorsOptions): Promise; 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..bb27524e581ef --- /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 { AppState } from '../../common'; +import { 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/insecure_cluster_service/insecure_cluster_service.test.tsx b/src/plugins/security_oss/public/insecure_cluster_service/insecure_cluster_service.test.tsx index 7bd2d9c4e5a0a..b74a45b598a39 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 { 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 0b4d7216bc443..9496d963b1bba 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 @@ -30,7 +30,7 @@ interface SetupDeps { } interface StartDeps { - core: Pick; + core: Pick; appState: AppStateServiceStart; } diff --git a/src/plugins/security_oss/public/plugin.ts b/src/plugins/security_oss/public/plugin.ts index 9750154d21286..49d9d9b436f20 100644 --- a/src/plugins/security_oss/public/plugin.ts +++ b/src/plugins/security_oss/public/plugin.ts @@ -32,18 +32,6 @@ import { } from './insecure_cluster_service'; import { AppStateService } from './app_state'; -export interface SavedObjectTypeAnonymousAccess { - /** - * Indicates whether anonymous user can access particular Saved Object type (e.g. dashboard, map etc.). - */ - canAccess: boolean; - /** - * A map of query string parameters that should be specified in URL so that anonymous user can use - * to automatically log in to Kibana and access particular Saved Object type. - */ - accessURLParameters: Record | null; -} - export interface SecurityOssPluginSetup { insecureCluster: InsecureClusterServiceSetup; } @@ -58,15 +46,11 @@ export interface SecurityOssPluginStart { export class SecurityOssPlugin implements Plugin { - private readonly config: ConfigType; - - private readonly insecureClusterService: InsecureClusterService; + private readonly config = this.initializerContext.config.get(); + private readonly insecureClusterService = new InsecureClusterService(this.config, localStorage); private readonly appStateService = new AppStateService(); - 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 { 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 40ac0b2794da0..d3de580cf4022 100644 --- a/src/plugins/security_oss/server/plugin.ts +++ b/src/plugins/security_oss/server/plugin.ts @@ -66,14 +66,7 @@ export interface AnonymousAccessService { export class SecurityOssPlugin implements Plugin { private readonly config$: Observable; private readonly logger: Logger; - private anonymousAccessServiceProvider?: () => AnonymousAccessService; - private readonly getAnonymousAccessService = () => { - if (!this.anonymousAccessServiceProvider) { - throw new Error('Anonymous Access service provider is not set.'); - } - return this.anonymousAccessServiceProvider(); - }; constructor(initializerContext: PluginInitializerContext) { this.config$ = initializerContext.config.create(); @@ -90,12 +83,12 @@ export class SecurityOssPlugin implements Plugin this.anonymousAccessServiceProvider?.() ?? null, }); setupAnonymousAccessCapabilitiesRoute({ router, - getAnonymousAccessService: this.getAnonymousAccessService, + getAnonymousAccessService: () => this.anonymousAccessServiceProvider?.() ?? null, }); return { diff --git a/src/plugins/security_oss/server/routes/anonymous_access_capabilities.ts b/src/plugins/security_oss/server/routes/anonymous_access_capabilities.ts index a1473cd9b6954..ca63405d78a5b 100644 --- a/src/plugins/security_oss/server/routes/anonymous_access_capabilities.ts +++ b/src/plugins/security_oss/server/routes/anonymous_access_capabilities.ts @@ -22,7 +22,7 @@ import { AnonymousAccessService } from '../plugin'; interface Deps { router: IRouter; - getAnonymousAccessService: () => AnonymousAccessService; + getAnonymousAccessService: () => AnonymousAccessService | null; } /** @@ -32,9 +32,12 @@ export function setupAnonymousAccessCapabilitiesRoute({ router, getAnonymousAcce router.get( { path: '/internal/security_oss/anonymous_access/capabilities', validate: false }, async (_context, request, response) => { - return response.ok({ - body: await getAnonymousAccessService().getCapabilities(request), - }); + 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/app_state.ts b/src/plugins/security_oss/server/routes/app_state.ts index 68777bf1fd1b0..a4e39ec003351 100644 --- a/src/plugins/security_oss/server/routes/app_state.ts +++ b/src/plugins/security_oss/server/routes/app_state.ts @@ -30,7 +30,7 @@ interface Deps { config$: Observable; displayModifier$: Observable; doesClusterHaveUserData: ReturnType; - getAnonymousAccessService: () => AnonymousAccessService; + getAnonymousAccessService: () => AnonymousAccessService | null; } export const setupAppStateRoute = ({ @@ -62,10 +62,10 @@ export const setupAppStateRoute = ({ const appState: AppState = { insecureClusterAlert: { displayAlert }, anonymousAccess: { - isEnabled: anonymousAccessService.isAnonymousAccessEnabled, - accessURLParameters: anonymousAccessService.accessURLParameters + isEnabled: !!anonymousAccessService?.isAnonymousAccessEnabled, + accessURLParameters: anonymousAccessService?.accessURLParameters ? Object.fromEntries(anonymousAccessService.accessURLParameters.entries()) - : anonymousAccessService.accessURLParameters, + : null, }, }; return response.ok({ body: appState }); 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..15d64ff85f2d5 --- /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 { 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/app_state.test.ts b/src/plugins/security_oss/server/routes/integration_tests/app_state.test.ts index faae814303c27..e68dd40bb01bb 100644 --- a/src/plugins/security_oss/server/routes/integration_tests/app_state.test.ts +++ b/src/plugins/security_oss/server/routes/integration_tests/app_state.test.ts @@ -19,6 +19,7 @@ import { loggingSystemMock } from '../../../../../core/server/mocks'; import { setupServer } from '../../../../../core/server/test_utils'; +import { AnonymousAccessService } from '../../plugin'; import { setupAppStateRoute } from '../app_state'; import { ConfigType } from '../../config'; import { BehaviorSubject, of } from 'rxjs'; @@ -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/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 index 99fd7414039c0..55509090af28a 100644 --- 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 @@ -4,17 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import type { - AnonymousAccessServiceSetup, - AnonymousAccessServiceStart, -} from './anonymous_access_service'; +import type { AnonymousAccessServiceStart } from './anonymous_access_service'; import { capabilitiesServiceMock } from '../../../../../src/core/server/mocks'; export const anonymousAccessServiceMock = { - createSetup: (): jest.Mocked => ({ - isAnonymousAccessEnabled: false, - }), createStart: (): jest.Mocked => ({ isAnonymousAccessEnabled: false, accessURLParameters: null, 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..c382eb5ef52b9 --- /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 { 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 index c057762ca3cb3..9989e184166b9 100644 --- a/x-pack/plugins/security/server/anonymous_access/anonymous_access_service.ts +++ b/x-pack/plugins/security/server/anonymous_access/anonymous_access_service.ts @@ -11,11 +11,12 @@ import { 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 } from '../authentication'; +import { AnonymousAuthenticationProvider, HTTPAuthorizationHeader } from '../authentication'; import type { ConfigType } from '../config'; import { getDetailedErrorMessage, getErrorStatusCode } from '../errors'; @@ -29,11 +30,10 @@ export interface AnonymousAccessServiceStart { interface AnonymousAccessServiceStartParams { basePath: IBasePath; capabilities: CapabilitiesStart; + clusterClient: IClusterClient; spaces?: SpacesServiceStart; } -const DEFAULT_CAPABILITIES: Capabilities = { navLinks: {}, management: {}, catalogue: {} }; - /** * Service that manages various aspects of the anonymous access. */ @@ -43,17 +43,32 @@ export class AnonymousAccessService { */ 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() { - this.isAnonymousAccessEnabled = this.getConfig().authc.sortedProviders.some( + 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(); @@ -77,43 +92,38 @@ export class AnonymousAccessService { getCapabilities: async (request) => { this.logger.debug('Retrieving capabilities of the anonymous service account.'); + let useDefaultCapabilities = false; if (!this.isAnonymousAccessEnabled) { - this.logger.warn('Anonymous access is not enabled'); - return DEFAULT_CAPABILITIES; + 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 figure out if the specified Saved Object type can be accessed anonymously. - const anonymousAuthorizationHeader = this.createAnonymousAuthorizationHeader(); - const fakeAnonymousRequest = KibanaRequest.from(({ - headers: anonymousAuthorizationHeader - ? { authorization: anonymousAuthorizationHeader.toString() } - : {}, - // We should pretend that this request is authenticated to force authorization service to - // perform a privileges check and not to automatically disable all capabilities. - auth: { isAuthenticated: true }, - path: '/', - route: { settings: {} }, - url: { href: '/' }, - raw: { req: { url: '/' } }, - } as unknown) as Request); - + const fakeAnonymousRequest = this.createFakeAnonymousRequest({ + authenticateRequest: !useDefaultCapabilities, + }); const spaceId = spaces?.getSpaceId(request); if (spaceId) { basePath.set(fakeAnonymousRequest, addSpaceIdToPath('/', spaceId)); } try { - return await capabilities.resolveCapabilities(fakeAnonymousRequest); + return await capabilities.resolveCapabilities(fakeAnonymousRequest, { + useDefaultCapabilities, + }); } catch (err) { - const errorMessage = getDetailedErrorMessage(err); - if (getErrorStatusCode(err) === 401) { - this.logger.warn(`Cannot authenticate anonymous service account: ${errorMessage}`); - return DEFAULT_CAPABILITIES; - } - this.logger.error( - `Failed to retrieve anonymous service account capabilities: ${errorMessage}` + `Failed to retrieve anonymous service account capabilities: ${getDetailedErrorMessage( + err + )}` ); throw err; } @@ -122,19 +132,47 @@ export class AnonymousAccessService { } /** - * Creates authorization header that is used to authentication anonymous users. + * Checks if anonymous service account can authenticate to Elasticsearch using configured credentials. + * @param clusterClient */ - private createAnonymousAuthorizationHeader() { - const config = this.getConfig(); - const anonymousProvider = config.authc.sortedProviders.find( - ({ type }) => type === AnonymousAuthenticationProvider.type - ); + 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; + } - return anonymousProvider - ? AnonymousAuthenticationProvider.createHTTPAuthorizationHeader( - (config.authc.providers.anonymous![anonymousProvider.name] as Record) - .credentials - ) - : null; + /** + * 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 index d7d6bd4300e08..62a5c4f6b27ae 100644 --- a/x-pack/plugins/security/server/anonymous_access/index.ts +++ b/x-pack/plugins/security/server/anonymous_access/index.ts @@ -4,8 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { - AnonymousAccessService, - AnonymousAccessServiceStart, - AnonymousAccessServiceSetup, -} from './anonymous_access_service'; +export { AnonymousAccessService, AnonymousAccessServiceStart } from './anonymous_access_service'; 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 cf43782ca3dc3..3b49b40d26559 100644 --- a/x-pack/plugins/security/server/errors.ts +++ b/x-pack/plugins/security/server/errors.ts @@ -5,7 +5,7 @@ */ import Boom from '@hapi/boom'; -import { ResponseError as ESResponseError } from '@elastic/elasticsearch/lib/errors'; +import { errors } from '@elastic/elasticsearch'; import type { CustomHttpResponseOptions, ResponseError } from '../../../../src/core/server'; export function wrapError(error: any) { @@ -30,7 +30,7 @@ export function wrapIntoCustomErrorResponse(error: any) { * @param error Error instance to extract status code from. */ export function getErrorStatusCode(error: any): number { - if (error instanceof ESResponseError) { + if (error instanceof errors.ResponseError) { return error.statusCode; } @@ -44,7 +44,7 @@ export function getErrorStatusCode(error: any): number { * @param error Error instance to extract message from. */ export function getDetailedErrorMessage(error: any): string { - if (error instanceof ESResponseError) { + if (error instanceof errors.ResponseError) { return JSON.stringify(error.body); } 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 ec2dc713a5a26..1016221cb719d 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -348,6 +348,7 @@ export class Plugin { this.anonymousAccessStart = this.anonymousAccessService.start({ capabilities: core.capabilities, + clusterClient, basePath: core.http.basePath, spaces: spaces?.spacesService, }); From e877c5b6b6d698aa4805fca62bc2d9b8f6da6ccb Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Mon, 11 Jan 2021 10:52:51 +0100 Subject: [PATCH 5/7] Minor tweaks and integration tests. --- .../app_state/app_state_service.mock.ts | 4 +- .../insecure_cluster_service.test.tsx | 2 +- .../insecure_cluster_service.tsx | 4 +- src/plugins/security_oss/public/plugin.ts | 4 +- src/plugins/security_oss/server/plugin.ts | 9 +- .../routes/anonymous_access_capabilities.ts | 4 +- .../security_oss/server/routes/app_state.ts | 4 +- .../anonymous_access_capabilities.test.ts | 2 +- .../anonymous_access_service.test.ts | 2 +- .../anonymous_access_service.ts | 2 +- .../tests/anonymous/capabilities.ts | 379 ++++++++++++++++++ .../tests/anonymous/index.ts | 1 + 12 files changed, 398 insertions(+), 19 deletions(-) create mode 100644 x-pack/test/security_api_integration/tests/anonymous/capabilities.ts 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 index bb27524e581ef..6eb628dd04b7e 100644 --- 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 @@ -17,8 +17,8 @@ * under the License. */ -import { AppState } from '../../common'; -import { AppStateServiceStart } from './app_state_service'; +import type { AppState } from '../../common'; +import type { AppStateServiceStart } from './app_state_service'; export const mockAppStateService = { createStart: (): jest.Mocked => { 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 b74a45b598a39..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 @@ -20,7 +20,7 @@ import { nextTick } from '@kbn/test/jest'; import { coreMock } from '../../../../core/public/mocks'; import { mockAppStateService } from '../app_state/app_state_service.mock'; -import { ConfigType } from '../config'; +import type { ConfigType } from '../config'; import { InsecureClusterService } from './insecure_cluster_service'; let mockOnDismissCallback: (persist: boolean) => void = jest.fn().mockImplementation(() => { 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 9496d963b1bba..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,8 +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 { AppStateServiceStart } from '../app_state'; +import type { ConfigType } from '../config'; +import type { AppStateServiceStart } from '../app_state'; import { defaultAlertText, defaultAlertTitle } from './components'; interface SetupDeps { diff --git a/src/plugins/security_oss/public/plugin.ts b/src/plugins/security_oss/public/plugin.ts index 49d9d9b436f20..f8fe1b7a11f4e 100644 --- a/src/plugins/security_oss/public/plugin.ts +++ b/src/plugins/security_oss/public/plugin.ts @@ -17,14 +17,14 @@ * under the License. */ -import { +import type { Capabilities, CoreSetup, CoreStart, Plugin, PluginInitializerContext, } from 'src/core/public'; -import { ConfigType } from './config'; +import type { ConfigType } from './config'; import { InsecureClusterService, InsecureClusterServiceSetup, diff --git a/src/plugins/security_oss/server/plugin.ts b/src/plugins/security_oss/server/plugin.ts index d3de580cf4022..43dd3eb758903 100644 --- a/src/plugins/security_oss/server/plugin.ts +++ b/src/plugins/security_oss/server/plugin.ts @@ -17,7 +17,7 @@ * under the License. */ -import { +import type { Capabilities, CoreSetup, KibanaRequest, @@ -37,8 +37,7 @@ export interface SecurityOssPluginSetup { showInsecureClusterWarning$: BehaviorSubject; /** - * Set the provider function that returns a service that can deal with various aspects of the - * anonymous access. + * Set the provider function that returns a service to deal with the anonymous access. * @param provider */ setAnonymousAccessServiceProvider: (provider: () => AnonymousAccessService) => void; @@ -51,8 +50,8 @@ export interface AnonymousAccessService { readonly isAnonymousAccessEnabled: boolean; /** - * A map of query string parameters that should be specified in URL so that anonymous user can use - * to automatically log in to Kibana. + * 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; diff --git a/src/plugins/security_oss/server/routes/anonymous_access_capabilities.ts b/src/plugins/security_oss/server/routes/anonymous_access_capabilities.ts index ca63405d78a5b..afa0aa340d94d 100644 --- a/src/plugins/security_oss/server/routes/anonymous_access_capabilities.ts +++ b/src/plugins/security_oss/server/routes/anonymous_access_capabilities.ts @@ -17,8 +17,8 @@ * under the License. */ -import { IRouter } from 'kibana/server'; -import { AnonymousAccessService } from '../plugin'; +import type { IRouter } from 'kibana/server'; +import type { AnonymousAccessService } from '../plugin'; interface Deps { router: IRouter; diff --git a/src/plugins/security_oss/server/routes/app_state.ts b/src/plugins/security_oss/server/routes/app_state.ts index a4e39ec003351..e8e1fb16e34b1 100644 --- a/src/plugins/security_oss/server/routes/app_state.ts +++ b/src/plugins/security_oss/server/routes/app_state.ts @@ -17,12 +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 type { ConfigType } from '../config'; -import { AnonymousAccessService } from '../plugin'; +import type { AnonymousAccessService } from '../plugin'; interface Deps { router: IRouter; 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 index 15d64ff85f2d5..c8c847f628133 100644 --- 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 @@ -17,7 +17,7 @@ * under the License. */ -import { UnwrapPromise } from '@kbn/utility-types'; +import type { UnwrapPromise } from '@kbn/utility-types'; import supertest from 'supertest'; import { setupServer } from '../../../../../core/server/test_utils'; import { AnonymousAccessService } from '../../plugin'; 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 index c382eb5ef52b9..5aee78c368e64 100644 --- 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 @@ -5,7 +5,7 @@ */ import { errors } from '@elastic/elasticsearch'; -import { Logger } from '../../../../../src/core/server'; +import type { Logger } from '../../../../../src/core/server'; import { ConfigSchema, createConfig } from '../config'; import { AnonymousAccessService } from './anonymous_access_service'; 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 index 9989e184166b9..5c752644eb1fa 100644 --- a/x-pack/plugins/security/server/anonymous_access/anonymous_access_service.ts +++ b/x-pack/plugins/security/server/anonymous_access/anonymous_access_service.ts @@ -132,7 +132,7 @@ export class AnonymousAccessService { } /** - * Checks if anonymous service account can authenticate to Elasticsearch using configured credentials. + * Checks if anonymous service account can authenticate to Elasticsearch using currently configured credentials. * @param clusterClient */ private async canAuthenticateAnonymousServiceAccount(clusterClient: IClusterClient) { 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..6a2c4db406cc6 --- /dev/null +++ b/x-pack/test/security_api_integration/tests/anonymous/capabilities.ts @@ -0,0 +1,379 @@ +/* + * 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 security = getService('security'); + const spaces = getService('spaces'); + + 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'], + }); + + 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'] } }, + ], + }); + }); + + after(async () => { + await spaces.delete('space-a'); + await spaces.delete('space-b'); + await security.role.delete('anonymous_role'); + }); + + 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', () => { + 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.user.create('anonymous_user', { + password: 'changeme', + roles: ['anonymous_role'], + full_name: 'Guest', + }); + }); + + after(async () => { + 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')); }); } From ea3156600629a746bb32b7e5bc859821f3b2cc3e Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Tue, 12 Jan 2021 14:19:03 +0100 Subject: [PATCH 6/7] Review#3: handle review nits and adjust anonymous access tests for the ES native anonymous user case. --- .../capabilities/capabilities_service.ts | 2 +- .../tests/anonymous/capabilities.ts | 64 ++++++++++++------- 2 files changed, 41 insertions(+), 25 deletions(-) diff --git a/src/core/server/capabilities/capabilities_service.ts b/src/core/server/capabilities/capabilities_service.ts index 57f8247304703..f18848e04f547 100644 --- a/src/core/server/capabilities/capabilities_service.ts +++ b/src/core/server/capabilities/capabilities_service.ts @@ -178,7 +178,7 @@ export class CapabilitiesService { public start(): CapabilitiesStart { return { resolveCapabilities: (request, options) => - this.resolveCapabilities(request, [], !!options?.useDefaultCapabilities), + this.resolveCapabilities(request, [], options?.useDefaultCapabilities ?? false), }; } } diff --git a/x-pack/test/security_api_integration/tests/anonymous/capabilities.ts b/x-pack/test/security_api_integration/tests/anonymous/capabilities.ts index 6a2c4db406cc6..f2f562c0c29c0 100644 --- a/x-pack/test/security_api_integration/tests/anonymous/capabilities.ts +++ b/x-pack/test/security_api_integration/tests/anonymous/capabilities.ts @@ -8,9 +8,14 @@ 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`) @@ -36,21 +41,11 @@ export default function ({ getService }: FtrProviderContext) { name: 'space-b', disabledFeatures: ['dashboard', 'maps'], }); - - 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'] } }, - ], - }); }); after(async () => { await spaces.delete('space-a'); await spaces.delete('space-b'); - await security.role.delete('anonymous_role'); }); describe('without anonymous service account', () => { @@ -152,17 +147,19 @@ export default function ({ getService }: FtrProviderContext) { }); describe('with anonymous service account without roles', () => { - before(async () => { - await security.user.create('anonymous_user', { - password: 'changeme', - roles: [], - full_name: 'Guest', + if (!isElasticsearchAnonymousAccessEnabled) { + before(async () => { + await security.user.create('anonymous_user', { + password: 'changeme', + roles: [], + full_name: 'Guest', + }); }); - }); - after(async () => { - await security.user.delete('anonymous_user'); - }); + after(async () => { + await security.user.delete('anonymous_user'); + }); + } it('all capabilities should be disabled', async () => { expectSnapshot(await getAnonymousCapabilities()).toMatchInline(` @@ -263,15 +260,34 @@ export default function ({ getService }: FtrProviderContext) { describe('with properly configured anonymous service account', () => { before(async () => { - await security.user.create('anonymous_user', { - password: 'changeme', - roles: ['anonymous_role'], - full_name: 'Guest', + 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.user.delete('anonymous_user'); + await security.role.delete('anonymous_role'); + + if (!isElasticsearchAnonymousAccessEnabled) { + await security.user.delete('anonymous_user'); + } }); it('capabilities should be properly defined', async () => { From 35d89adfaee5ecdc66c458199879745cc33280ff Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Fri, 15 Jan 2021 07:56:01 +0100 Subject: [PATCH 7/7] Review#3: handle review nits. --- src/plugins/security_oss/public/plugin.ts | 4 ++-- src/plugins/security_oss/server/routes/app_state.ts | 2 +- .../server/anonymous_access/anonymous_access_service.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/plugins/security_oss/public/plugin.ts b/src/plugins/security_oss/public/plugin.ts index f8fe1b7a11f4e..756e20f34cfde 100644 --- a/src/plugins/security_oss/public/plugin.ts +++ b/src/plugins/security_oss/public/plugin.ts @@ -67,8 +67,8 @@ export class SecurityOssPlugin const { anonymousAccess } = await appState.getState(); return anonymousAccess.accessURLParameters; }, - async getCapabilities() { - return await core.http.get( + getCapabilities() { + return core.http.get( '/internal/security_oss/anonymous_access/capabilities' ); }, diff --git a/src/plugins/security_oss/server/routes/app_state.ts b/src/plugins/security_oss/server/routes/app_state.ts index e8e1fb16e34b1..a20f1938d7c92 100644 --- a/src/plugins/security_oss/server/routes/app_state.ts +++ b/src/plugins/security_oss/server/routes/app_state.ts @@ -62,7 +62,7 @@ export const setupAppStateRoute = ({ const appState: AppState = { insecureClusterAlert: { displayAlert }, anonymousAccess: { - isEnabled: !!anonymousAccessService?.isAnonymousAccessEnabled, + isEnabled: anonymousAccessService?.isAnonymousAccessEnabled ?? false, accessURLParameters: anonymousAccessService?.accessURLParameters ? Object.fromEntries(anonymousAccessService.accessURLParameters.entries()) : null, 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 index 5c752644eb1fa..66b1e91e12bce 100644 --- a/x-pack/plugins/security/server/anonymous_access/anonymous_access_service.ts +++ b/x-pack/plugins/security/server/anonymous_access/anonymous_access_service.ts @@ -106,7 +106,7 @@ export class AnonymousAccessService { } // We should use credentials of the anonymous service account instead of credentials of the - // current user to figure out if the specified Saved Object type can be accessed anonymously. + // current user to get capabilities relevant to the anonymous access itself. const fakeAnonymousRequest = this.createFakeAnonymousRequest({ authenticateRequest: !useDefaultCapabilities, });