diff --git a/src/core/packages/elasticsearch/server-internal/src/cps_request_handler.test.ts b/src/core/packages/elasticsearch/server-internal/src/cps_request_handler.test.ts new file mode 100644 index 0000000000000..506b3bf2d3591 --- /dev/null +++ b/src/core/packages/elasticsearch/server-internal/src/cps_request_handler.test.ts @@ -0,0 +1,139 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { TransportRequestParams } from '@elastic/elasticsearch'; +import { CpsRequestHandler } from './cps_request_handler'; + +const LOCAL_PROJECT_ROUTING = '_alias:_origin'; + +describe('CpsRequestHandler', () => { + describe('when CPS is enabled', () => { + const onRequest = new CpsRequestHandler(true).onRequest; + + it('injects default project_routing into body', () => { + const params: TransportRequestParams = { + method: 'GET', + path: '/_search', + meta: { name: 'search', acceptedParams: ['project_routing'] }, + }; + onRequest({ scoped: false }, params, {}); + }); + + it('does not inject when API does not support project_routing', () => { + const params: TransportRequestParams = { + method: 'GET', + path: '/_cat/indices', + meta: { name: 'cat/indices', acceptedParams: [] }, + }; + + onRequest({ scoped: true }, params, {}); + + expect(params.body).toBeUndefined(); + }); + + it('does not override project_routing already present in body', () => { + const params: TransportRequestParams = { + method: 'GET', + path: '/_search', + meta: { name: 'search', acceptedParams: ['project_routing'] }, + body: { project_routing: 'custom-value' }, + }; + + onRequest({ scoped: true }, params, {}); + + expect((params.body as Record)?.project_routing).toBe('custom-value'); + }); + + it('does not inject project_routing for PIT-based searches', () => { + const params: TransportRequestParams = { + method: 'POST', + path: '/_search', + body: { pit: { id: 'abc123' } }, + meta: { name: 'search', acceptedParams: ['project_routing'] }, + }; + + onRequest({ scoped: true }, params, {}); + + expect((params.body as Record)?.project_routing).toBeUndefined(); + }); + + it('strips project_routing from body for PIT-based searches', () => { + const params: TransportRequestParams = { + method: 'POST', + path: '/_search', + body: { pit: { id: 'abc123' }, project_routing: 'should-be-removed' }, + meta: { name: 'search', acceptedParams: ['project_routing'] }, + }; + + onRequest({ scoped: true }, params, {}); + + expect((params.body as Record)?.project_routing).toBeUndefined(); + expect((params.body as Record)?.pit).toEqual({ id: 'abc123' }); + }); + + it('preserves existing body fields when injecting', () => { + const params: TransportRequestParams = { + method: 'GET', + path: '/_search', + meta: { name: 'search', acceptedParams: ['project_routing'] }, + body: { query: { match_all: {} } }, + }; + + onRequest({ scoped: true }, params, {}); + + expect(params.body).toEqual({ + query: { match_all: {} }, + project_routing: LOCAL_PROJECT_ROUTING, + }); + }); + }); + + describe('when CPS is disabled', () => { + const onRequest = new CpsRequestHandler(false).onRequest; + + it('does not inject project_routing', () => { + const params: TransportRequestParams = { + method: 'GET', + path: '/_search', + meta: { name: 'search', acceptedParams: ['project_routing'] }, + }; + + onRequest({ scoped: true }, params, {}); + + expect(params.body).toBeUndefined(); + }); + + it('strips project_routing from body', () => { + const params: TransportRequestParams = { + method: 'GET', + path: '/_search', + meta: { name: 'search', acceptedParams: ['project_routing'] }, + body: { query: { match_all: {} }, project_routing: 'should-be-removed' }, + }; + + onRequest({ scoped: true }, params, {}); + + expect((params.body as Record)?.project_routing).toBeUndefined(); + expect((params.body as Record)?.query).toEqual({ match_all: {} }); + }); + + it('strips project_routing even when API does not support it', () => { + const params: TransportRequestParams = { + method: 'GET', + path: '/_bulk', + meta: { name: 'bulk', acceptedParams: [] }, + body: { project_routing: 'should-be-stripped' }, + }; + + onRequest({ scoped: true }, params, {}); + + expect((params.body as Record)?.project_routing).toBeUndefined(); + }); + }); +}); diff --git a/src/core/packages/elasticsearch/server-internal/src/cps_request_handler.ts b/src/core/packages/elasticsearch/server-internal/src/cps_request_handler.ts new file mode 100644 index 0000000000000..1fa3d7435596a --- /dev/null +++ b/src/core/packages/elasticsearch/server-internal/src/cps_request_handler.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { set } from '@kbn/safer-lodash-set'; +import { isPlainObject } from 'lodash'; +import type { OnRequestHandler } from '@kbn/core-elasticsearch-client-server-internal'; + +const LOCAL_PROJECT_ROUTING = '_alias:_origin'; + +/** @internal */ +export class CpsRequestHandler { + constructor(private readonly cpsEnabled: boolean) {} + + public readonly onRequest: OnRequestHandler = (_ctx, params, _options) => { + const body = isPlainObject(params.body) ? (params.body as Record) : undefined; + + if (this.cpsEnabled) { + if (this.shouldApplyProjectRouting(params.meta?.acceptedParams)) + if (body?.pit) { + // The project_routing is set by the openPit API, and thus part of the PIT context. + this.stripProjectRouting(body); + } else { + this.injectProjectRouting(params, body); + } + } else { + this.stripProjectRouting(body); + } + }; + + private stripProjectRouting(body: Record | undefined): void { + if (body?.project_routing != null) { + delete body.project_routing; + } + } + + private injectProjectRouting( + params: Parameters[1], + body: Record | undefined + ): void { + if (!body?.project_routing) { + set(params, 'body.project_routing', LOCAL_PROJECT_ROUTING); + } + } + + private shouldApplyProjectRouting(acceptedParams: string[] | undefined): boolean { + return Boolean(acceptedParams?.includes('project_routing')); + } +} diff --git a/src/core/packages/elasticsearch/server-internal/src/elasticsearch_service.test.ts b/src/core/packages/elasticsearch/server-internal/src/elasticsearch_service.test.ts index d7393a53bdf38..7ba95e3117e2f 100644 --- a/src/core/packages/elasticsearch/server-internal/src/elasticsearch_service.test.ts +++ b/src/core/packages/elasticsearch/server-internal/src/elasticsearch_service.test.ts @@ -26,7 +26,7 @@ import { } from './elasticsearch_service.test.mocks'; import type { NodesVersionCompatibility } from './version_check/ensure_es_version'; -import { BehaviorSubject, firstValueFrom, of } from 'rxjs'; +import { BehaviorSubject, firstValueFrom, of, throwError } from 'rxjs'; import { first, concatMap } from 'rxjs'; import { REPO_ROOT } from '@kbn/repo-info'; import { Env } from '@kbn/config'; @@ -89,7 +89,10 @@ beforeEach(() => { verificationMode: 'none', }, }); - configService.atPath.mockReturnValue(mockConfig$); + configService.atPath.mockImplementation((path) => { + if (path === 'elasticsearch') return mockConfig$; + return new BehaviorSubject({}); + }); const logger = loggingSystemMock.create(); coreContext = { coreId: Symbol(), env, logger, configService: configService as any }; @@ -519,167 +522,83 @@ describe('#stop', () => { }); describe('CPS onRequest handler', () => { - describe('in non-serverless mode', () => { - it('does not pass onRequest handler to ClusterClient', async () => { - await elasticsearchService.setup(setupDeps); + it('passes onRequest to ClusterClient in non-serverless mode', async () => { + await elasticsearchService.setup(setupDeps); - expect(MockClusterClient).toHaveBeenCalledWith( - expect.objectContaining({ - onRequest: undefined, - }) - ); - }); + expect(MockClusterClient).toHaveBeenCalledWith( + expect.objectContaining({ onRequest: expect.any(Function) }) + ); }); - describe('in serverless mode', () => { - let serverlessEnv: Env; - let serverlessCoreContext: CoreContext; - let serverlessElasticsearchService: ElasticsearchService; - - beforeEach(() => { - serverlessEnv = Env.createDefault( - REPO_ROOT, - getEnvOptions({ cliArgs: { serverless: true } }) - ); - const logger = loggingSystemMock.create(); - serverlessCoreContext = { - coreId: Symbol(), - env: serverlessEnv, - logger, - configService: configService as any, - }; - serverlessElasticsearchService = new ElasticsearchService(serverlessCoreContext); + it('passes onRequest to ClusterClient in serverless mode when CPS is enabled', async () => { + configService.atPath.mockImplementation((path) => { + if (path === 'elasticsearch') return mockConfig$; + if (path === 'cps') return new BehaviorSubject({ cpsEnabled: true }); + return new BehaviorSubject({}); }); - - afterEach(async () => { - await serverlessElasticsearchService?.stop(); + const serverlessEnv = Env.createDefault( + REPO_ROOT, + getEnvOptions({ cliArgs: { serverless: true } }) + ); + const serverlessService = new ElasticsearchService({ + coreId: Symbol(), + env: serverlessEnv, + logger: loggingSystemMock.create(), + configService: configService as any, }); + await serverlessService.setup(setupDeps); - describe('onRequest handler behavior', () => { - type OnRequestHandler = (ctx: { scoped: boolean }, params: any, options?: any) => void; - let onRequestHandler: OnRequestHandler; - const LOCAL_PROJECT_ROUTING = '_alias:_origin'; - - const setCpsEnabled = (enabled: boolean) => { - // Access private property for testing - (serverlessElasticsearchService as any).cpsEnabled = enabled; - }; - - beforeEach(async () => { - MockClusterClient.mockClear(); - await serverlessElasticsearchService.setup(setupDeps); - onRequestHandler = MockClusterClient.mock.calls[0][0].onRequest; - }); - - it('injects project_routing for unscoped requests', () => { - const options: any = {}; - const params = { - method: 'GET', - path: '/_search', - meta: { acceptedParams: ['project_routing'] }, - }; - - setCpsEnabled(true); - - onRequestHandler({ scoped: false }, params, options); - - expect((params as any).body?.project_routing).toBe(LOCAL_PROJECT_ROUTING); - }); - - it('does not inject project_routing when CPS is disabled', () => { - const options: any = {}; - const params = { - method: 'GET', - path: '/_search', - meta: { acceptedParams: ['project_routing'] }, - }; - - onRequestHandler({ scoped: true }, params as any, options); - - expect((params as any).body?.project_routing).toBeUndefined(); - }); - - it('does not inject project_routing when API does not support it', () => { - const options: any = {}; - const params = { - method: 'GET', - path: '/_cat/indices', - meta: { acceptedParams: [] }, - }; - - setCpsEnabled(true); - - onRequestHandler({ scoped: true }, params, options); - - expect((params as any).body?.project_routing).toBeUndefined(); - }); - - it('does not inject project_routing when it is already set', () => { - const options: any = {}; - const params = { - method: 'GET', - path: '/_search', - meta: { acceptedParams: ['project_routing'] }, - body: { project_routing: 'custom-value' }, - }; - - setCpsEnabled(true); - - onRequestHandler({ scoped: true }, params, options); - - expect((params as any).body?.project_routing).toBe('custom-value'); - }); - - it('does not inject project_routing for PIT requests', () => { - const options: any = {}; - const params = { - method: 'POST', - path: '/_search', - body: { pit: { id: 'abc123' } }, - meta: { acceptedParams: ['project_routing'] }, - }; - - setCpsEnabled(true); - - onRequestHandler({ scoped: true }, params, options); - - expect((params as any).body?.project_routing).toBeUndefined(); - }); - - it('injects project_routing when all conditions are met', () => { - const options: any = {}; - const params = { - method: 'GET', - path: '/_search', - meta: { acceptedParams: ['project_routing'] }, - body: { project_routing: LOCAL_PROJECT_ROUTING }, - }; - - setCpsEnabled(true); - - onRequestHandler({ scoped: true }, params, options); - - expect(params.body.project_routing).toBe(LOCAL_PROJECT_ROUTING); - }); - - it('preserves existing param body when injecting project_routing', () => { - const options: any = {}; - const params = { - method: 'GET', - path: '/_search', - meta: { acceptedParams: ['project_routing'] }, - body: { field1: 'value1', project_routing: LOCAL_PROJECT_ROUTING }, - }; + expect(MockClusterClient).toHaveBeenCalledWith( + expect.objectContaining({ onRequest: expect.any(Function) }) + ); + await serverlessService.stop(); + }); - setCpsEnabled(true); + it('passes onRequest to ClusterClient in serverless mode when CPS is disabled', async () => { + configService.atPath.mockImplementation((path) => { + if (path === 'elasticsearch') return mockConfig$; + if (path === 'cps') return new BehaviorSubject({ cpsEnabled: false }); + return new BehaviorSubject({}); + }); + const serverlessEnv = Env.createDefault( + REPO_ROOT, + getEnvOptions({ cliArgs: { serverless: true } }) + ); + const serverlessService = new ElasticsearchService({ + coreId: Symbol(), + env: serverlessEnv, + logger: loggingSystemMock.create(), + configService: configService as any, + }); + await serverlessService.setup(setupDeps); - onRequestHandler({ scoped: true }, params, options); + expect(MockClusterClient).toHaveBeenCalledWith( + expect.objectContaining({ onRequest: expect.any(Function) }) + ); + await serverlessService.stop(); + }); - expect(params.body).toEqual({ - field1: 'value1', - project_routing: LOCAL_PROJECT_ROUTING, - }); - }); + it('treats cpsEnabled as false when atPath("cps") observable errors', async () => { + configService.atPath.mockImplementation((path) => { + if (path === 'elasticsearch') return mockConfig$; + if (path === 'cps') return throwError(() => new Error('cps config unavailable')); + return new BehaviorSubject({}); }); + const serverlessEnv = Env.createDefault( + REPO_ROOT, + getEnvOptions({ cliArgs: { serverless: true } }) + ); + const serverlessService = new ElasticsearchService({ + coreId: Symbol(), + env: serverlessEnv, + logger: loggingSystemMock.create(), + configService: configService as any, + }); + await serverlessService.setup(setupDeps); + + expect(MockClusterClient).toHaveBeenCalledWith( + expect.objectContaining({ onRequest: expect.any(Function) }) + ); + await serverlessService.stop(); }); }); diff --git a/src/core/packages/elasticsearch/server-internal/src/elasticsearch_service.ts b/src/core/packages/elasticsearch/server-internal/src/elasticsearch_service.ts index 5ec3cebdd3623..3a013e35cef53 100644 --- a/src/core/packages/elasticsearch/server-internal/src/elasticsearch_service.ts +++ b/src/core/packages/elasticsearch/server-internal/src/elasticsearch_service.ts @@ -8,7 +8,6 @@ */ import type { Observable } from 'rxjs'; -import { set } from '@kbn/safer-lodash-set'; import { map, takeUntil, firstValueFrom, Subject } from 'rxjs'; import type { Logger } from '@kbn/logging'; @@ -25,13 +24,8 @@ import type { ElasticsearchClientConfig, ElasticsearchCapabilities, } from '@kbn/core-elasticsearch-server'; -import { - ClusterClient, - AgentManager, - type OnRequestHandler, -} from '@kbn/core-elasticsearch-client-server-internal'; +import { ClusterClient, AgentManager } from '@kbn/core-elasticsearch-client-server-internal'; -import { isPlainObject } from 'lodash'; import type { InternalSecurityServiceSetup } from '@kbn/core-security-server-internal'; import { registerAnalyticsContextProvider } from './register_analytics_context_provider'; import type { ElasticsearchConfigType } from './elasticsearch_config'; @@ -49,6 +43,7 @@ import { isInlineScriptingEnabled } from './is_scripting_enabled'; import { mergeConfig } from './merge_config'; import { type ClusterInfo, getClusterInfo$ } from './get_cluster_info'; import { getElasticsearchCapabilities } from './get_capabilities'; +import { CpsRequestHandler } from './cps_request_handler'; export interface SetupDeps { analytics: AnalyticsServiceSetup; @@ -64,6 +59,7 @@ export class ElasticsearchService private readonly log: Logger; private readonly config$: Observable; private readonly isServerless: boolean; + private cpsRequestHandler!: CpsRequestHandler; private stop$ = new Subject(); private kibanaVersion: string; private authHeaders?: IAuthHeadersStorage; @@ -73,7 +69,6 @@ export class ElasticsearchService private clusterInfo$?: Observable; private unauthorizedErrorHandler?: UnauthorizedErrorHandler; private agentManager?: AgentManager; - private cpsEnabled = false; private security?: InternalSecurityServiceSetup; constructor(private readonly coreContext: CoreContext) { @@ -106,6 +101,16 @@ export class ElasticsearchService const config = await firstValueFrom(this.config$); + // TODO we should find a better method to determine whether the underlying ES is CPS-capable. + const cpsEnabled = this.isServerless + ? ( + await firstValueFrom( + this.coreContext.configService.atPath<{ cpsEnabled?: boolean }>('cps') + ).catch(() => ({ cpsEnabled: false })) + ).cpsEnabled ?? false + : false; + this.cpsRequestHandler = new CpsRequestHandler(cpsEnabled); + const agentManager = this.getAgentManager(config); this.authHeaders = deps.http.authRequestHeaders; @@ -153,10 +158,6 @@ export class ElasticsearchService getAgentsStats: agentManager.getAgentsStats.bind(agentManager), }, publicBaseUrl: config.publicBaseUrl, - setCpsFeatureFlag: (enabled) => { - this.cpsEnabled = enabled; - this.log.info(`CPS feature flag set to ${enabled}`); - }, }; } @@ -251,7 +252,7 @@ export class ElasticsearchService getUnauthorizedErrorHandler: () => this.unauthorizedErrorHandler, agentFactoryProvider: this.getAgentManager(baseConfig), kibanaVersion: this.kibanaVersion, - onRequest: this.getOnRequestHandler(), + onRequest: this.cpsRequestHandler?.onRequest, }); } @@ -263,25 +264,4 @@ export class ElasticsearchService } return this.agentManager; } - - private getOnRequestHandler(): OnRequestHandler | undefined { - if (!this.isServerless) return undefined; - - return (ctx, params, options) => { - // Note: this.cpsEnabled may be set at a later point in time - if (!this.cpsEnabled) return; - const body = params.body; - if ( - isPlainObject(body) && - ((body as Record).project_routing != null || - (body as Record).pit != null) - ) - return; - - const acceptedParams = params.meta?.acceptedParams; - const apiSupportsProjectRouting = acceptedParams?.includes('project_routing') ?? false; - if (!apiSupportsProjectRouting) return; - set(params, 'body.project_routing', '_alias:_origin'); - }; - } } diff --git a/src/core/packages/elasticsearch/server-mocks/src/elasticsearch_service.mock.ts b/src/core/packages/elasticsearch/server-mocks/src/elasticsearch_service.mock.ts index b8fae0b88c663..b1256939f7f9b 100644 --- a/src/core/packages/elasticsearch/server-mocks/src/elasticsearch_service.mock.ts +++ b/src/core/packages/elasticsearch/server-mocks/src/elasticsearch_service.mock.ts @@ -65,7 +65,6 @@ const createPrebootContractMock = () => { const createSetupContractMock = () => { const setupContract: MockedElasticSearchServiceSetup = lazyObject({ setUnauthorizedErrorHandler: jest.fn(), - setCpsFeatureFlag: jest.fn(), legacy: { config$: new BehaviorSubject({} as ElasticsearchConfig), }, diff --git a/src/core/packages/elasticsearch/server/src/contracts.ts b/src/core/packages/elasticsearch/server/src/contracts.ts index a3a26ef740656..60f31d315964a 100644 --- a/src/core/packages/elasticsearch/server/src/contracts.ts +++ b/src/core/packages/elasticsearch/server/src/contracts.ts @@ -97,17 +97,6 @@ export interface ElasticsearchServiceSetup { */ readonly publicBaseUrl?: string; - - /** - * Sets the CPS feature flag in the Elasticsearch service. - * This should only be called from the CPS plugin. - * - * @example - * ```ts - * core.elasticsearch.setCpsFeatureFlag(true); - * ``` - */ - setCpsFeatureFlag: (enabled: boolean) => void; } /** diff --git a/src/core/packages/plugins/server-internal/src/plugin_context.ts b/src/core/packages/plugins/server-internal/src/plugin_context.ts index 93ca5f0b75804..8aefb7d6dd3f2 100644 --- a/src/core/packages/plugins/server-internal/src/plugin_context.ts +++ b/src/core/packages/plugins/server-internal/src/plugin_context.ts @@ -221,7 +221,6 @@ export function createPluginSetupContext({ legacy: deps.elasticsearch.legacy, publicBaseUrl: deps.elasticsearch.publicBaseUrl, setUnauthorizedErrorHandler: deps.elasticsearch.setUnauthorizedErrorHandler, - setCpsFeatureFlag: deps.elasticsearch.setCpsFeatureFlag, }, executionContext: { withContext: deps.executionContext.withContext, diff --git a/src/core/server/integration_tests/elasticsearch/project_routing_non_serverless.test.ts b/src/core/server/integration_tests/elasticsearch/project_routing_non_serverless.test.ts new file mode 100644 index 0000000000000..ee03e984cf849 --- /dev/null +++ b/src/core/server/integration_tests/elasticsearch/project_routing_non_serverless.test.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +/** + * @jest-environment node + */ + +import { createTestServers } from '@kbn/core-test-helpers-kbn-server'; + +const LOCAL_PROJECT_ROUTING = '_alias:_origin'; + +describe('project_routing on non-serverless', () => { + it('Kibana removes project_routing information from requests when not serverless', async () => { + // When not serverless, ElasticsearchService sets cpsEnabled to false (elasticsearch_service.ts), + // so CpsRequestHandler strips project_routing. The cps plugin config only exposes cpsEnabled + // for the serverless offering (offeringBasedSchema), so are not allowed to enable it here. + const { startES, startKibana } = createTestServers({ + adjustTimeout: (timeout: number) => jest.setTimeout(timeout), + }); + const esServer = await startES(); + const kibanaServer = await startKibana(); + const esClient = kibanaServer.coreStart.elasticsearch.client.asInternalUser; + + try { + const response = await esClient.search({ + index: '.kibana', + size: 0, + body: { + // @ts-expect-error - project_routing is a valid body parameter + project_routing: LOCAL_PROJECT_ROUTING, + }, + }); + expect(response.hits.hits).toBeDefined(); + } finally { + await kibanaServer.stop(); + await esServer.stop(); + } + }); +}); diff --git a/src/core/server/integration_tests/elasticsearch/cps_project_routing.test.ts b/src/core/server/integration_tests/elasticsearch/project_routing_serverless_cps.test.ts similarity index 99% rename from src/core/server/integration_tests/elasticsearch/cps_project_routing.test.ts rename to src/core/server/integration_tests/elasticsearch/project_routing_serverless_cps.test.ts index 301f18f320153..304e646b1c25b 100644 --- a/src/core/server/integration_tests/elasticsearch/cps_project_routing.test.ts +++ b/src/core/server/integration_tests/elasticsearch/project_routing_serverless_cps.test.ts @@ -54,7 +54,7 @@ const SORTED_COUNTS_DESC = [...TEST_DOCUMENTS] * * @see src/core/packages/elasticsearch/server-internal/src/elasticsearch_service.ts */ -describe('CPS project_routing on serverless ES', () => { +describe('project_routing on serverless CPS', () => { let serverlessES: TestServerlessESUtils; let serverlessKibana: TestServerlessKibanaUtils; let client: ElasticsearchClient; diff --git a/src/core/server/integration_tests/elasticsearch/project_routing_serverless_non_cps.test.ts b/src/core/server/integration_tests/elasticsearch/project_routing_serverless_non_cps.test.ts new file mode 100644 index 0000000000000..2f19dc6101b48 --- /dev/null +++ b/src/core/server/integration_tests/elasticsearch/project_routing_serverless_non_cps.test.ts @@ -0,0 +1,165 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +/** + * @jest-environment node + */ + +import type { + TestServerlessESUtils, + TestServerlessKibanaUtils, +} from '@kbn/core-test-helpers-kbn-server'; +import { createTestServerlessInstances } from '@kbn/core-test-helpers-kbn-server'; +import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; +import { systemIndicesSuperuser } from '@kbn/test'; + +const SYSTEM_INDEX = '.kibana'; +const TEST_INDEX = 'cps-routing-integration-test'; +const ALL_PROJECT_ROUTING = '_alias:*'; + +/** + * Integration tests for CPS (Cross-Project Search) project_routing parameter. + * + * It's important that we properly strip the `project_routing` parameter. Incorrect + * injection can cause 400 errors for requests that should otherwise pass, and risk + * of breaking requests to ES at a large scale. The integration tests send real requests + * to a real ES server to empirically verify that such errors do not happen. + * + * These tests start a serverless ES instance with CPS disabled (`enableCPS: false`). + * + * @see src/core/packages/elasticsearch/server-internal/src/elasticsearch_service.ts + */ +describe('project_routing on serverless non-CPS', () => { + let serverlessES: TestServerlessESUtils; + let serverlessKibana: TestServerlessKibanaUtils; + let client: ElasticsearchClient; + + beforeAll(async () => { + const { startES } = serverlessInstances(false); + serverlessES = await startES(); + }); + + describe(`'cps' plugin disabled`, () => { + beforeAll(async () => { + const { startKibana } = serverlessInstances(false); + serverlessKibana = await startKibana(); + client = serverlessKibana.coreStart.elasticsearch.client.asInternalUser; + await client.indices.create({ index: TEST_INDEX }); + }); + + afterAll(async () => { + await client?.indices.delete({ index: TEST_INDEX }).catch(() => {}); + await serverlessKibana?.stop(); + }); + + it('does NOT inject project_routing', async () => { + await expect( + client.search({ index: SYSTEM_INDEX, query: { match_all: {} } }) + ).resolves.not.toThrow(); + + await expect( + client.search({ index: TEST_INDEX, query: { match_all: {} } }) + ).resolves.not.toThrow(); + }); + + it('strips project_routing', async () => { + await expect( + client.search({ + index: SYSTEM_INDEX, + query: { match_all: {} }, + // @ts-expect-error - project_routing is a valid body parameter + body: { project_routing: ALL_PROJECT_ROUTING }, + }) + ).resolves.not.toThrow(); + + await expect( + client.search({ + index: TEST_INDEX, + query: { match_all: {} }, + // @ts-expect-error - project_routing is a valid body parameter + body: { project_routing: ALL_PROJECT_ROUTING }, + }) + ).resolves.not.toThrow(); + }); + }); + + describe(`'cps' plugin enabled`, () => { + beforeAll(async () => { + const { startKibana } = serverlessInstances(true); + serverlessKibana = await startKibana(); + client = serverlessKibana.coreStart.elasticsearch.client.asInternalUser; + await client.indices.create({ index: TEST_INDEX }); + }); + + afterAll(async () => { + await client?.indices.delete({ index: TEST_INDEX }).catch(() => {}); + await serverlessKibana?.stop(); + }); + + it('injects project_routing and requests fail', async () => { + await expect( + client.search({ index: SYSTEM_INDEX, query: { match_all: {} } }) + ).rejects.toThrow(); + + await expect( + client.search({ index: TEST_INDEX, query: { match_all: {} } }) + ).rejects.toThrow(); + }); + + it('does NOT strip project_routing and requests fail', async () => { + await expect( + client.search({ + index: SYSTEM_INDEX, + query: { match_all: {} }, + // @ts-expect-error - project_routing is a valid body parameter + body: { project_routing: ALL_PROJECT_ROUTING }, + }) + ).rejects.toThrow(); + + await expect( + client.search({ + index: TEST_INDEX, + query: { match_all: {} }, + // @ts-expect-error - project_routing is a valid body parameter + body: { project_routing: ALL_PROJECT_ROUTING }, + }) + ).rejects.toThrow(); + }); + }); + + afterAll(async () => { + await serverlessES?.stop(); + }); +}); + +const serverlessInstances = (cpsPlugin: boolean) => { + return createTestServerlessInstances({ + adjustTimeout: (timeout: number) => jest.setTimeout(timeout), + enableCPS: false, + // Match `yarn es serverless --projectType observability ...` + projectType: 'oblt', + // Required to apply the UIAM/serverless ES args block (mock IDP/project metadata). + kibanaUrl: 'http://localhost:5601/', + // Setup-only: use superuser so tests can create temp indices. + kibana: { + settings: { + ...(cpsPlugin && { + cps: { + enabled: true, + cpsEnabled: true, + }, + }), + elasticsearch: { + username: systemIndicesSuperuser.username, + password: systemIndicesSuperuser.password, + }, + }, + }, + }); +}; diff --git a/src/core/test-helpers/kbn-server/src/create_serverless_root.ts b/src/core/test-helpers/kbn-server/src/create_serverless_root.ts index b041706cb8aa7..04e0ba2e9087d 100644 --- a/src/core/test-helpers/kbn-server/src/create_serverless_root.ts +++ b/src/core/test-helpers/kbn-server/src/create_serverless_root.ts @@ -106,7 +106,10 @@ export function createTestServerlessInstances({ if (enableCPS) { if (!kibana.settings) kibana.settings = {}; - set(kibana.settings, 'cps.cpsEnabled', true); + const hasCpsKey = kibana.settings && 'cps' in kibana.settings; + if (!hasCpsKey) { + set(kibana.settings, 'cps.cpsEnabled', enableCPS); + } // Match the default `yarn es serverless --uiam` setup, but allow tests to override // auth by pre-setting `elasticsearch.username/password` (e.g. use `system_indices_superuser`). const existingEsSettings = (kibana.settings as any).elasticsearch ?? {}; diff --git a/src/platform/plugins/shared/cps/server/plugin.test.ts b/src/platform/plugins/shared/cps/server/plugin.test.ts index 5222158d62bf8..f7dfef97a7599 100644 --- a/src/platform/plugins/shared/cps/server/plugin.test.ts +++ b/src/platform/plugins/shared/cps/server/plugin.test.ts @@ -35,11 +35,6 @@ describe('CPSServerPlugin', () => { const setup = plugin.setup(mockCoreSetup); expect(setup.getCpsEnabled()).toBe(true); }); - - it('should call setCpsFeatureFlag with true', () => { - plugin.setup(mockCoreSetup); - expect(mockCoreSetup.elasticsearch.setCpsFeatureFlag).toHaveBeenCalledWith(true); - }); }); describe('when cpsEnabled is false', () => { @@ -53,11 +48,6 @@ describe('CPSServerPlugin', () => { const setup = plugin.setup(mockCoreSetup); expect(setup.getCpsEnabled()).toBe(false); }); - - it('should call setCpsFeatureFlag with false', () => { - plugin.setup(mockCoreSetup); - expect(mockCoreSetup.elasticsearch.setCpsFeatureFlag).toHaveBeenCalledWith(false); - }); }); it('should register routes in serverless mode', () => { diff --git a/src/platform/plugins/shared/cps/server/plugin.ts b/src/platform/plugins/shared/cps/server/plugin.ts index ed687416dbebd..9aea68155ca5b 100644 --- a/src/platform/plugins/shared/cps/server/plugin.ts +++ b/src/platform/plugins/shared/cps/server/plugin.ts @@ -27,14 +27,10 @@ export class CPSServerPlugin implements Plugin { const { initContext, config$ } = this; const { cpsEnabled } = config$; - // Register route only for serverless if (this.isServerless) { registerRoutes(core, initContext); } - // Set CPS feature flag in Elasticsearch service - core.elasticsearch.setCpsFeatureFlag(cpsEnabled); - return { getCpsEnabled: () => cpsEnabled, };