diff --git a/server/auth/types/authentication_type.ts b/server/auth/types/authentication_type.ts index 56ec21463..1ffeb76cf 100755 --- a/server/auth/types/authentication_type.ts +++ b/server/auth/types/authentication_type.ts @@ -37,6 +37,11 @@ export interface IAuthenticationType { type: string; authHandler: AuthenticationHandler; init: () => Promise; + requestIncludesAuthInfo(request: OpenSearchDashboardsRequest): boolean; + buildAuthHeaderFromCookie( + cookie: SecuritySessionCookie, + request: OpenSearchDashboardsRequest + ): any; } export type IAuthHandlerConstructor = new ( @@ -267,7 +272,6 @@ export abstract class AuthenticationType implements IAuthenticationType { } // abstract functions for concrete auth types to implement - public abstract requestIncludesAuthInfo(request: OpenSearchDashboardsRequest): boolean; public abstract getAdditionalAuthHeader(request: OpenSearchDashboardsRequest): Promise; public abstract getCookie( request: OpenSearchDashboardsRequest, @@ -282,9 +286,5 @@ export abstract class AuthenticationType implements IAuthenticationType { response: LifecycleResponseFactory, toolkit: AuthToolkit ): IOpenSearchDashboardsResponse | AuthResult; - public abstract buildAuthHeaderFromCookie( - cookie: SecuritySessionCookie, - request: OpenSearchDashboardsRequest - ): any; public abstract init(): Promise; } diff --git a/server/plugin.ts b/server/plugin.ts index c6aec6e58..0f266c1fe 100644 --- a/server/plugin.ts +++ b/server/plugin.ts @@ -15,7 +15,6 @@ import { first } from 'rxjs/operators'; import { Observable } from 'rxjs'; -import { ResponseObject } from '@hapi/hapi'; import { PluginInitializerContext, CoreSetup, @@ -39,16 +38,14 @@ import { ISavedObjectTypeRegistry, } from '../../../src/core/server/saved_objects'; import { setupIndexTemplate, migrateTenantIndices } from './multitenancy/tenant_index'; -import { - IAuthenticationType, - OpenSearchDashboardsAuthState, -} from './auth/types/authentication_type'; +import { IAuthenticationType } from './auth/types/authentication_type'; import { getAuthenticationHandler } from './auth/auth_handler_factory'; import { setupMultitenantRoutes } from './multitenancy/routes'; import { defineAuthTypeRoutes } from './routes/auth_type_routes'; import { createMigrationOpenSearchClient } from '../../../src/core/server/saved_objects/migrations/core'; import { SecuritySavedObjectsClientWrapper } from './saved_objects/saved_objects_wrapper'; import { addTenantParameterToResolvedShortLink } from './multitenancy/tenant_resolver'; +import { ReadonlyService } from './readonly/readonly_service'; export interface SecurityPluginRequestContext { logger: Logger; @@ -138,6 +135,7 @@ export class SecurityPlugin implements Plugin = {}): SecuritySessionCookie => + Object.assign( + { + username: 'test', + credentials: { + authHeaderValue: 'Basic cmVhZG9ubHk6Z2FzZGN4ejRRIQ==', + }, + authType: 'basicauth', + isAnonymousAuth: false, + tenant: '__user__', + }, + data + ); + +const mockEsClient = (): jest.Mocked => { + return { + callAsInternalUser: jest.fn(), + asScoped: jest.fn(), + }; +}; + +const mockAuthInfo = (data: Partial = {}): OpenSearchAuthInfo => + Object.assign( + { + user: '', + user_name: 'admin', + user_requested_tenant: PRIVATE_TENANT_SYMBOL, + remote_address: '127.0.0.1', + backend_roles: ['admin'], + custom_attribute_names: [], + roles: ['own_index', 'all_access'], + tenants: { + admin_tenant: true, + admin: true, + }, + principal: null, + peer_certificates: '0', + sso_logout_url: null, + }, + data + ); + +const mockDashboardsInfo = (data = {}) => + Object.assign( + { + user_name: 'admin', + multitenancy_enabled: true, + }, + data + ); + +const getService = ( + cookie: SecuritySessionCookie = mockCookie(), + authInfo: OpenSearchAuthInfo = mockAuthInfo(), + dashboardsInfo = mockDashboardsInfo() +) => { + const logger = loggerMock.create(); + + const securityClient = new SecurityClient(mockEsClient()); + securityClient.authinfo = jest.fn().mockReturnValue(authInfo); + securityClient.dashboardsinfo = jest.fn().mockReturnValue(dashboardsInfo); + + // @ts-ignore mock auth + const auth = new BasicAuthentication(); + auth.requestIncludesAuthInfo = jest.fn().mockReturnValue(true); + + const securitySessionStorageFactory = sessionStorageMock.createFactory(); + securitySessionStorageFactory.asScoped = jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue(cookie), + }); + + const config = { + multitenancy: { + enabled: true, + }, + } as SecurityPluginConfigType; + + return new ReadonlyService(logger, securityClient, auth, securitySessionStorageFactory, config); +}; + +describe('checks isAnonymousPage', () => { + const service = getService(); + + it.each([ + // Missing referer header + [ + { + path: '/api/core/capabilities', + headers: {}, + auth: { + isAuthenticated: false, + mode: 'optional', + }, + }, + false, + ], + // Referer with not anynoumous page + [ + { + headers: { + referer: 'https://localhost/app/management/opensearch-dashboards/indexPatterns', + }, + }, + false, + ], + // Referer with anynoumous page + [ + { + path: '/app/login', + headers: { + referer: 'https://localhost/app/login', + }, + routeAuthRequired: false, + }, + true, + ], + ])('%j returns result %s', (requestData, expectedResult) => { + const request = httpServerMock.createOpenSearchDashboardsRequest(requestData); + expect(service.isAnonymousPage(request)).toEqual(expectedResult); + }); +}); + +describe('checks isReadOnlyTenant', () => { + const service = getService(); + + it.each([ + // returns false with private global tenant + [mockAuthInfo({ user_requested_tenant: PRIVATE_TENANT_SYMBOL }), false], + // returns false when has requested tenant but it's read and write + [ + mockAuthInfo({ + user_requested_tenant: 'readonly_tenant', + tenants: { + readonly_tenant: true, + }, + }), + false, + ], + // returns true when has requested tenant and it's read only + [ + mockAuthInfo({ + user_requested_tenant: 'readonly_tenant', + tenants: { + readonly_tenant: false, + }, + }), + true, + ], + ])('%j returns result %s', (authInfo, expectedResult) => { + expect(service.isReadOnlyTenant(authInfo)).toBe(expectedResult); + }); +}); + +describe('checks isReadonly', () => { + it('calls isAnonymousPage', async () => { + const service = getService(); + service.isAnonymousPage = jest.fn(() => true); + await service.isReadonly(httpServerMock.createOpenSearchDashboardsRequest()); + expect(service.isAnonymousPage).toBeCalled(); + }); + it('calls isReadOnlyTenant with correct authinfo', async () => { + const cookie = mockCookie({ tenant: 'readonly_tenant' }); + const authInfo = mockAuthInfo({ + user_requested_tenant: 'readonly_tenant', + tenants: { + readonly_tenant: false, + }, + }); + + const service = getService(cookie, authInfo); + service.isAnonymousPage = jest.fn(() => false); + + const result = await service.isReadonly(httpServerMock.createOpenSearchDashboardsRequest()); + expect(result).toBeTruthy(); + }); + it('calls dashboardInfo and checks if multitenancy is enabled', async () => { + const dashboardsInfo = mockDashboardsInfo({ multitenancy_enabled: false }); + const service = getService(mockCookie(), mockAuthInfo(), dashboardsInfo); + service.isAnonymousPage = jest.fn(() => false); + + const result = await service.isReadonly(httpServerMock.createOpenSearchDashboardsRequest()); + expect(result).toBeFalsy(); + }); +}); diff --git a/server/readonly/readonly_service.ts b/server/readonly/readonly_service.ts new file mode 100644 index 000000000..6e690b5f7 --- /dev/null +++ b/server/readonly/readonly_service.ts @@ -0,0 +1,119 @@ +/* + * Copyright OpenSearch Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * or in the "license" file accompanying this file. This file 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 { URL } from 'url'; +import { + Logger, + OpenSearchDashboardsRequest, + SessionStorageFactory, +} from '../../../../src/core/server'; +import { + globalTenantName, + isPrivateTenant, + LOGIN_PAGE_URI, + CUSTOM_ERROR_PAGE_URI, +} from '../../common'; +import { SecurityClient } from '../backend/opensearch_security_client'; +import { IAuthenticationType, OpenSearchAuthInfo } from '../auth/types/authentication_type'; +import { SecuritySessionCookie } from '../session/security_cookie'; +import { SecurityPluginConfigType } from '../index'; +import { ReadonlyService as BaseReadonlyService } from '../../../../src/core/server/security/readonly_service'; + +export class ReadonlyService extends BaseReadonlyService { + protected static readonly ROUTES_TO_IGNORE: string[] = [LOGIN_PAGE_URI, CUSTOM_ERROR_PAGE_URI]; + + private readonly logger: Logger; + private readonly securityClient: SecurityClient; + private readonly auth: IAuthenticationType; + private readonly securitySessionStorageFactory: SessionStorageFactory; + private readonly config: SecurityPluginConfigType; + + constructor( + logger: Logger, + securityClient: SecurityClient, + auth: IAuthenticationType, + securitySessionStorageFactory: SessionStorageFactory, + config: SecurityPluginConfigType + ) { + super(); + this.logger = logger; + this.securityClient = securityClient; + this.auth = auth; + this.securitySessionStorageFactory = securitySessionStorageFactory; + this.config = config; + } + + isAnonymousPage(request: OpenSearchDashboardsRequest) { + if (typeof request.route.options.authRequired === 'boolean') { + return !request.route.options.authRequired; + } + + if (!request.headers || !request.headers.referer) { + return false; + } + + const url = new URL(request.headers.referer as string); + return ReadonlyService.ROUTES_TO_IGNORE.some((path) => url.pathname?.includes(path)); + } + + isReadOnlyTenant(authInfo: OpenSearchAuthInfo): boolean { + const currentTenant = authInfo.user_requested_tenant || globalTenantName; + + // private tenants are isolated to individual users that always have read/write permissions + if (isPrivateTenant(currentTenant)) { + return false; + } + + const readWriteAccess = authInfo.tenants[currentTenant]; + return !readWriteAccess; + } + + async isReadonly(request: OpenSearchDashboardsRequest): Promise { + if (!this.config?.multitenancy.enabled) { + return false; + } + + // omit for anonymous pages to avoid authentication errors + if (this.isAnonymousPage(request)) { + return false; + } + + try { + const cookie = await this.securitySessionStorageFactory.asScoped(request).get(); + let headers = request.headers; + + if (!this.auth.requestIncludesAuthInfo(request) && cookie) { + headers = this.auth.buildAuthHeaderFromCookie(cookie, request); + } + + const dashboardsInfo = await this.securityClient.dashboardsinfo(request, headers); + + if (!dashboardsInfo.multitenancy_enabled) { + return false; + } + + const authInfo = await this.securityClient.authinfo(request, headers); + + if (!authInfo.user_requested_tenant && cookie) { + authInfo.user_requested_tenant = cookie.tenant; + } + + return authInfo && this.isReadOnlyTenant(authInfo); + } catch (error: any) { + this.logger.error(`Failed to resolve if it's a readonly tenant: ${error.stack}`); + return false; + } + } +}