diff --git a/src/core/packages/elasticsearch/client-server-internal/src/cluster_client.test.ts b/src/core/packages/elasticsearch/client-server-internal/src/cluster_client.test.ts index 42ed677cc3d3f..72377eeb679fe 100644 --- a/src/core/packages/elasticsearch/client-server-internal/src/cluster_client.test.ts +++ b/src/core/packages/elasticsearch/client-server-internal/src/cluster_client.test.ts @@ -19,6 +19,7 @@ import type { ElasticsearchClientConfig, ElasticsearchClient, } from '@kbn/core-elasticsearch-server'; +import { KBN_PROJECT_ROUTING_HEADER } from '@kbn/cps-server-utils'; import { getRequestHandlerFactory } from './cps_request_handler'; import { ClusterClient } from './cluster_client'; import type { OnRequestHandler } from './create_transport'; @@ -344,7 +345,7 @@ describe('ClusterClient', () => { ); }); - it("injects '_alias:_origin' when projectRouting is 'origin-only'", () => { + it("injects '_alias:_origin' when no projectRouting opts are provided (default)", () => { const onRequest = captureTransportOnRequest(); const clusterClient = new ClusterClient({ @@ -358,7 +359,7 @@ describe('ClusterClient', () => { }); const request = httpServerMock.createKibanaRequest(); - client = clusterClient.asScoped(request, { projectRouting: 'origin-only' }).asCurrentUser; + client = clusterClient.asScoped(request).asCurrentUser; const params = makeSearchParams(); onRequest.get()({} as never, params, {}, logger); @@ -366,7 +367,7 @@ describe('ClusterClient', () => { expect((params.body as Record).project_routing).toBe('_alias:_origin'); }); - it("injects '_alias:*' when projectRouting is 'all'", () => { + it("reads routing from the x-kbn-project-routing header when projectRouting is 'request-header'", () => { const onRequest = captureTransportOnRequest(); const clusterClient = new ClusterClient({ @@ -379,8 +380,12 @@ describe('ClusterClient', () => { onRequestHandlerFactory: mockOnRequestHandlerFactory, }); - const request = httpServerMock.createKibanaRequest(); - client = clusterClient.asScoped(request, { projectRouting: 'all' }).asCurrentUser; + const request = httpServerMock.createKibanaRequest({ + headers: { [KBN_PROJECT_ROUTING_HEADER]: '_alias:*' }, + }); + client = clusterClient.asScoped(request, { + projectRouting: 'request-header', + }).asCurrentUser; const params = makeSearchParams(); onRequest.get()({} as never, params, {}, logger); @@ -388,6 +393,30 @@ describe('ClusterClient', () => { expect((params.body as Record).project_routing).toBe('_alias:*'); }); + it("falls back to '_alias:_origin' when projectRouting is 'request-header' but header is absent", () => { + const onRequest = captureTransportOnRequest(); + + const clusterClient = new ClusterClient({ + config: createConfig(), + logger, + type: 'custom-type', + authHeaders, + agentFactoryProvider, + kibanaVersion, + onRequestHandlerFactory: mockOnRequestHandlerFactory, + }); + + const request = httpServerMock.createKibanaRequest(); + client = clusterClient.asScoped(request, { + projectRouting: 'request-header', + }).asCurrentUser; + + const params = makeSearchParams(); + onRequest.get()({} as never, params, {}, logger); + + expect((params.body as Record).project_routing).toBe('_alias:_origin'); + }); + // Note: child() clients that do NOT override Transport inherit the parent's routing // automatically, because the ES client propagates the Transport class to child clients. // However, callers who pass { Transport: CustomTransport } to child() bypass our @@ -1386,7 +1415,9 @@ describe('ClusterClient', () => { // Even when the scoped client is created with 'space' routing, asSecondaryAuthUser // is always a child of asInternalUser, which uses origin-only routing. const request = httpServerMock.createKibanaRequest({ path: '/s/my-space/app/discover' }); - client = clusterClient.asScoped(request, { projectRouting: 'space' }).asSecondaryAuthUser; + client = clusterClient.asScoped(request, { + projectRouting: 'space', + }).asSecondaryAuthUser; // No Transport override means the child inherits asInternalUser's origin-only Transport. expect(internalClient.child).toHaveBeenCalledWith( @@ -1568,7 +1599,7 @@ describe('ClusterClient', () => { }); const request = httpServerMock.createKibanaRequest(); - client = clusterClient.asScoped(request, { projectRouting: 'origin-only' }).asCurrentUser; + client = clusterClient.asScoped(request).asCurrentUser; const params = makeSearchParamsWithRouting(); onRequest.get()({} as never, params, {}, logger); @@ -1590,7 +1621,7 @@ describe('ClusterClient', () => { }); const request = httpServerMock.createKibanaRequest(); - client = clusterClient.asScoped(request, { projectRouting: 'origin-only' }).asCurrentUser; + client = clusterClient.asScoped(request).asCurrentUser; const params = makeSearchParams(); onRequest.get()({} as never, params, {}, logger); diff --git a/src/core/packages/elasticsearch/client-server-internal/src/cluster_client.ts b/src/core/packages/elasticsearch/client-server-internal/src/cluster_client.ts index 63bbaa0d45f01..464247b67914e 100644 --- a/src/core/packages/elasticsearch/client-server-internal/src/cluster_client.ts +++ b/src/core/packages/elasticsearch/client-server-internal/src/cluster_client.ts @@ -9,7 +9,7 @@ import type { Client } from '@elastic/elasticsearch'; import type { Logger } from '@kbn/logging'; -import type { Headers, IAuthHeadersStorage } from '@kbn/core-http-server'; +import type { Headers, IAuthHeadersStorage, KibanaRequest } from '@kbn/core-http-server'; import { ensureRawRequest, filterHeaders, @@ -24,9 +24,8 @@ import type { IScopedClusterClient, ElasticsearchClientConfig, AsScopedOptions, - OriginOnlyRouting, SpaceNPRERouting, - AllProjectsRouting, + RequestHeaderRouting, } from '@kbn/core-elasticsearch-server'; import { HTTPAuthorizationHeader, isUiamCredential } from '@kbn/core-security-server'; import type { InternalSecurityServiceSetup } from '@kbn/core-security-server-internal'; @@ -47,14 +46,35 @@ import type { AgentFactoryProvider } from './agent_manager'; const noop = () => undefined; +interface CommonFactoryRoutingOpts { + logger: Logger; +} + +interface SpaceFactoryRoutingOpts extends CommonFactoryRoutingOpts { + projectRouting: 'space'; + request: ScopeableUrlRequest; +} + +interface RequestHeaderFactoryRoutingOpts extends CommonFactoryRoutingOpts { + projectRouting: 'request-header'; + request: ScopeableRequest; +} + +/** + * Discriminated union of routing options passed to {@link OnRequestHandlerFactory}. + * Each variant carries exactly the data needed for that routing mode. + * @internal + */ +export type FactoryRoutingOpts = + | CommonFactoryRoutingOpts + | SpaceFactoryRoutingOpts + | RequestHeaderFactoryRoutingOpts; + /** * A factory that produces an {@link OnRequestHandler}, which can be bound to a request context. * @internal */ -export type OnRequestHandlerFactory = (opts: { - projectRouting: 'origin-only' | 'all' | ScopeableUrlRequest; - logger: Logger; -}) => OnRequestHandler; +export type OnRequestHandlerFactory = (opts: FactoryRoutingOpts) => OnRequestHandler; /** @internal **/ export class ClusterClient implements ICustomClusterClient { @@ -103,10 +123,7 @@ export class ClusterClient implements ICustomClusterClient { this.getUnauthorizedErrorHandler = getUnauthorizedErrorHandler; this.onRequestHandlerFactory = onRequestHandlerFactory; - const internalUserOnRequest = onRequestHandlerFactory({ - projectRouting: 'origin-only', - logger, - }); + const internalUserOnRequest = onRequestHandlerFactory({ logger }); this.asInternalUser = configureClient(config, { logger, @@ -127,25 +144,17 @@ export class ClusterClient implements ICustomClusterClient { }); } - asScoped(request: ScopeableUrlRequest, opts: SpaceNPRERouting): IScopedClusterClient; - asScoped( - request: ScopeableRequest, - opts?: OriginOnlyRouting | AllProjectsRouting - ): IScopedClusterClient; - asScoped(request: ScopeableRequest, opts: AsScopedOptions = { projectRouting: 'origin-only' }) { + asScoped(request: KibanaRequest, opts: AsScopedOptions): IScopedClusterClient; + asScoped(request: ScopeableRequest, opts?: RequestHeaderRouting): IScopedClusterClient; + asScoped(request: ScopeableUrlRequest, opts?: SpaceNPRERouting): IScopedClusterClient; + asScoped(request: ScopeableRequest, opts?: AsScopedOptions): IScopedClusterClient { const createScopedClient = () => { const scopedHeaders = this.getScopedHeaders(request); - const { projectRouting } = opts; - const transportClass = createTransport({ scoped: true, getExecutionContext: this.getExecutionContext, getUnauthorizedErrorHandler: this.createInternalErrorHandlerAccessor(request), - onRequest: this.onRequestHandlerFactory({ - projectRouting: - projectRouting === 'space' ? (request as ScopeableUrlRequest) : projectRouting, - logger: this.logger, - }), + onRequest: this.onRequestHandlerFactory({ ...opts, logger: this.logger, request }), logger: this.logger, }); diff --git a/src/core/packages/elasticsearch/client-server-internal/src/cps_request_handler/cps_request_handler_factory.test.ts b/src/core/packages/elasticsearch/client-server-internal/src/cps_request_handler/cps_request_handler_factory.test.ts index bbbe42ea6f604..dd8697423786c 100644 --- a/src/core/packages/elasticsearch/client-server-internal/src/cps_request_handler/cps_request_handler_factory.test.ts +++ b/src/core/packages/elasticsearch/client-server-internal/src/cps_request_handler/cps_request_handler_factory.test.ts @@ -10,7 +10,11 @@ import type { TransportRequestParams } from '@elastic/elasticsearch'; import type { Logger } from '@kbn/logging'; import { httpServerMock } from '@kbn/core-http-server-mocks'; -import { PROJECT_ROUTING_ORIGIN, PROJECT_ROUTING_ALL, getSpaceNPRE } from '@kbn/cps-server-utils'; +import { + PROJECT_ROUTING_ORIGIN, + KBN_PROJECT_ROUTING_HEADER, + getSpaceNPRE, +} from '@kbn/cps-server-utils'; import { loggerMock } from '@kbn/logging-mocks'; import { getRequestHandlerFactory } from './cps_request_handler_factory'; @@ -29,9 +33,9 @@ describe('getRequestHandlerFactory', () => { }); describe('without request (internal user)', () => { - it('injects PROJECT_ROUTING_ORIGIN when projectRouting is origin-only', () => { + it('injects PROJECT_ROUTING_ORIGIN when no opts are passed', () => { const factory = getRequestHandlerFactory(true); - const handler = factory({ projectRouting: 'origin-only', logger: mockLogger }); + const handler = factory({ logger: mockLogger }); const params = makeSearchParams(); handler({ scoped: false }, params, {}, mockLogger); @@ -40,52 +44,72 @@ describe('getRequestHandlerFactory', () => { }); }); - describe("projectRouting: 'origin-only'", () => { - it('injects PROJECT_ROUTING_ORIGIN when CPS is enabled', () => { + describe("projectRouting: 'space'", () => { + it('injects the space NPRE derived from a KibanaRequest', () => { const factory = getRequestHandlerFactory(true); - const handler = factory({ projectRouting: 'origin-only', logger: mockLogger }); + const request = httpServerMock.createKibanaRequest({ path: '/s/my-space/app/discover' }); + const handler = factory({ projectRouting: 'space', request, logger: mockLogger }); const params = makeSearchParams(); handler({ scoped: true }, params, {}, mockLogger); - expect((params.body as Record).project_routing).toBe(PROJECT_ROUTING_ORIGIN); + expect((params.body as Record).project_routing).toBe( + getSpaceNPRE('my-space') + ); }); + }); - it('strips project_routing when CPS is disabled', () => { - const factory = getRequestHandlerFactory(false); - const handler = factory({ projectRouting: 'origin-only', logger: mockLogger }); - const params = makeSearchParams({ project_routing: 'should-be-removed' }); + describe("projectRouting: 'request-header'", () => { + it('injects the routing value from the x-kbn-project-routing header when present', () => { + const factory = getRequestHandlerFactory(true); + const request = httpServerMock.createKibanaRequest({ + headers: { [KBN_PROJECT_ROUTING_HEADER]: '_alias:*' }, + }); + const handler = factory({ projectRouting: 'request-header', request, logger: mockLogger }); + const params = makeSearchParams(); handler({ scoped: true }, params, {}, mockLogger); - expect((params.body as Record).project_routing).toBeUndefined(); + expect((params.body as Record).project_routing).toBe('_alias:*'); }); - }); - describe("projectRouting: 'all'", () => { - it('injects PROJECT_ROUTING_ALL when CPS is enabled', () => { + it('falls back to PROJECT_ROUTING_ORIGIN when the header is absent', () => { const factory = getRequestHandlerFactory(true); - const handler = factory({ projectRouting: 'all', logger: mockLogger }); + const request = httpServerMock.createKibanaRequest(); + const handler = factory({ projectRouting: 'request-header', request, logger: mockLogger }); const params = makeSearchParams(); handler({ scoped: true }, params, {}, mockLogger); - expect((params.body as Record).project_routing).toBe(PROJECT_ROUTING_ALL); + expect((params.body as Record).project_routing).toBe(PROJECT_ROUTING_ORIGIN); }); - }); - describe("projectRouting: 'space'", () => { - it('injects the space NPRE derived from a KibanaRequest', () => { + it('takes the first value when the header is an array', () => { const factory = getRequestHandlerFactory(true); - const request = httpServerMock.createKibanaRequest({ path: '/s/my-space/app/discover' }); - const handler = factory({ projectRouting: request, logger: mockLogger }); + // Use a plain FakeRequest so the headers object is mutable, allowing us to set + // an array value that simulates multi-value HTTP header parsing by Node.js. + const request = { + headers: { [KBN_PROJECT_ROUTING_HEADER]: ['_alias:_origin', '_alias:*'] }, + }; + const handler = factory({ projectRouting: 'request-header', request, logger: mockLogger }); const params = makeSearchParams(); handler({ scoped: true }, params, {}, mockLogger); - expect((params.body as Record).project_routing).toBe( - getSpaceNPRE('my-space') - ); + expect((params.body as Record).project_routing).toBe('_alias:_origin'); + }); + + it('strips project_routing when CPS is disabled, regardless of header', () => { + const factory = getRequestHandlerFactory(false); + const request = httpServerMock.createKibanaRequest({ + headers: { [KBN_PROJECT_ROUTING_HEADER]: '_alias:*' }, + }); + const handler = factory({ projectRouting: 'request-header', request, logger: mockLogger }); + const params = makeSearchParams({ project_routing: 'should-be-removed' }); + + handler({ scoped: true }, params, {}, mockLogger); + + expect((params.body as Record).project_routing).toBeUndefined(); }); }); }); diff --git a/src/core/packages/elasticsearch/client-server-internal/src/cps_request_handler/cps_request_handler_factory.ts b/src/core/packages/elasticsearch/client-server-internal/src/cps_request_handler/cps_request_handler_factory.ts index dace38fea98e2..d678955b6d811 100644 --- a/src/core/packages/elasticsearch/client-server-internal/src/cps_request_handler/cps_request_handler_factory.ts +++ b/src/core/packages/elasticsearch/client-server-internal/src/cps_request_handler/cps_request_handler_factory.ts @@ -7,7 +7,11 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { getSpaceNPRE, PROJECT_ROUTING_ORIGIN, PROJECT_ROUTING_ALL } from '@kbn/cps-server-utils'; +import { + getSpaceNPRE, + PROJECT_ROUTING_ORIGIN, + KBN_PROJECT_ROUTING_HEADER, +} from '@kbn/cps-server-utils'; import type { OnRequestHandlerFactory } from '../cluster_client'; import { getCpsRequestHandler } from './cps_request_handler'; @@ -18,15 +22,17 @@ import { getCpsRequestHandler } from './cps_request_handler'; * @internal */ export function getRequestHandlerFactory(cpsEnabled: boolean): OnRequestHandlerFactory { - return ({ projectRouting, logger }) => { - switch (projectRouting) { - case 'origin-only': - return getCpsRequestHandler(cpsEnabled, PROJECT_ROUTING_ORIGIN, logger); - case 'all': - return getCpsRequestHandler(cpsEnabled, PROJECT_ROUTING_ALL, logger); - default: - // projectRouting is a ScopeableUrlRequest - derive the NPRE from its URL. - return getCpsRequestHandler(cpsEnabled, getSpaceNPRE(projectRouting), logger); + return (opts) => { + if ('projectRouting' in opts) { + if (opts.projectRouting === 'space') { + return getCpsRequestHandler(cpsEnabled, getSpaceNPRE(opts.request), opts.logger); + } else { + const raw = opts.request.headers[KBN_PROJECT_ROUTING_HEADER]; + const value = Array.isArray(raw) ? raw[0] : raw; + return getCpsRequestHandler(cpsEnabled, value ?? PROJECT_ROUTING_ORIGIN, opts.logger); + } + } else { + return getCpsRequestHandler(cpsEnabled, PROJECT_ROUTING_ORIGIN, opts.logger); } }; } diff --git a/src/core/packages/elasticsearch/server/index.ts b/src/core/packages/elasticsearch/server/index.ts index 05ba282797d0b..467311d29bed8 100644 --- a/src/core/packages/elasticsearch/server/index.ts +++ b/src/core/packages/elasticsearch/server/index.ts @@ -14,9 +14,8 @@ export type { IClusterClient, ICustomClusterClient, AsScopedOptions, - OriginOnlyRouting, SpaceNPRERouting, - AllProjectsRouting, + RequestHeaderRouting, ScopeableRequest, UnauthorizedErrorHandlerResult, UnauthorizedErrorHandler, diff --git a/src/core/packages/elasticsearch/server/src/client/cluster_client.ts b/src/core/packages/elasticsearch/server/src/client/cluster_client.ts index a23e39ae3d2b0..3b7e6bad06598 100644 --- a/src/core/packages/elasticsearch/server/src/client/cluster_client.ts +++ b/src/core/packages/elasticsearch/server/src/client/cluster_client.ts @@ -7,6 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import type { KibanaRequest } from '@kbn/core-http-server'; import type { ElasticsearchClient } from './client'; import type { ScopeableRequest, ScopeableUrlRequest } from './types'; import type { IScopedClusterClient } from './scoped_cluster_client'; @@ -27,32 +28,24 @@ export interface AsScopedOptions { * header and Elasticsearch handles execution, security enforcement, and result aggregation. * * **Options**: - * - `'origin-only'`: Requests are routed exclusively to the "origin" Elasticsearch instance - * (i.e., the project that Kibana is directly connected to). Use this for administrative or - * internal operations that must not fan out across other projects. - * - `'space'`: Requests are routed to the Named Project Routing Expression (NPRE) configured for - * the current Kibana space. Requires a {@link ScopeableUrlRequest} to be passed to `asScoped` - * so that the space can be extracted from the URL pathname. Use this when the scope of the - * query should match the data boundaries of the active space. - * - `'all'`: Requests are broadcast to all CPS-connected Elasticsearch instances. This is the - * broadest option and is appropriate when the intent is to search or aggregate data across - * all connected projects. + * - `'space'`: Requests are routed to the Named Project Routing Expression (NPRE) configured + * for the current Kibana space. Requires a {@link ScopeableUrlRequest} to be passed to + * `asScoped` so that the space can be extracted from the URL pathname. Use this when the scope + * of the query should match the data boundaries of the active space (e.g. alerting rules). + * - `'request-header'`: The `project_routing` value is read from the `x-kbn-project-routing` + * HTTP header present on the incoming request. Browser-side code sets this header via the + * Kibana HTTP client, eliminating the need to propagate the routing value through request + * bodies or service layers. Falls back to origin-only routing when the header is absent. + * + * When no options are passed to `asScoped`, requests are always routed to the origin project + * (i.e. the Elasticsearch instance Kibana is directly connected to). * * **Important**: This option only takes effect in CPS-enabled Serverless environments. In all * other environments (stateful, non-CPS Serverless), any `project_routing` params are * stripped from requests to avoid Elasticsearch rejections and to preserve traditional * single-cluster routing behavior. */ - projectRouting: 'origin-only' | 'space' | 'all'; -} - -/** - * {@link AsScopedOptions} variant that locks routing to the origin Elasticsearch instance. - * Use for administrative or internal operations that must not fan out across CPS-connected projects. - * @public - */ -export interface OriginOnlyRouting extends AsScopedOptions { - projectRouting: 'origin-only'; + projectRouting: 'space' | 'request-header'; } /** @@ -66,12 +59,15 @@ export interface SpaceNPRERouting extends AsScopedOptions { } /** - * {@link AsScopedOptions} variant that broadcasts requests to all CPS-connected Elasticsearch - * instances. Use when the intent is to search or aggregate data across all available projects. + * {@link AsScopedOptions} variant that reads `project_routing` from the `x-kbn-project-routing` + * HTTP header present on the incoming request. Browser-side code sets this header via the Kibana + * HTTP client (e.g. `http.post('/api/...', { headers: { 'x-kbn-project-routing': value } })`), + * eliminating the need to propagate the value through request bodies or service layers. + * Falls back to origin-only routing when the header is absent. * @public */ -export interface AllProjectsRouting extends AsScopedOptions { - projectRouting: 'all'; +export interface RequestHeaderRouting extends AsScopedOptions { + projectRouting: 'request-header'; } /** @@ -92,35 +88,23 @@ export interface IClusterClient { * to `'origin-only'` routing - they will never fan out to other CPS-connected projects. */ readonly asInternalUser: ElasticsearchClient; + + asScoped(request: KibanaRequest, opts: AsScopedOptions): IScopedClusterClient; + asScoped(request: ScopeableRequest, opts?: RequestHeaderRouting): IScopedClusterClient; /** * Creates a {@link IScopedClusterClient | scoped cluster client} bound to the given request, * forwarding the request's authentication headers to Elasticsearch. * - * In CPS-enabled Serverless environments, the `opts` parameter controls how `project_routing` - * is injected into outgoing requests. See {@link AsScopedOptions} for details. - * - * @param request - A {@link ScopeableUrlRequest} whose URL is used to extract the active space - * for space-level CPS routing. Accepts both a real {@link KibanaRequest} (the typical caller - * from route handlers) and a synthetic {@link UrlRequest}. - * @param opts - {@link SpaceNPRERouting} options with `projectRouting` set to `'space'`. - */ - asScoped(request: ScopeableUrlRequest, opts: SpaceNPRERouting): IScopedClusterClient; - /** - * Creates a {@link IScopedClusterClient | scoped cluster client} bound to the given request, - * forwarding the request's authentication headers to Elasticsearch. - * - * In CPS-enabled Serverless environments, the `opts` parameter controls how `project_routing` - * is injected into outgoing requests. See {@link AsScopedOptions} for details. - * - * @param request - The incoming {@link ScopeableRequest | request} whose credentials are used - * to authenticate Elasticsearch calls. - * @param opts - Optional {@link AsScopedOptions | options} to configure CPS routing behavior. - * Defaults to `'origin-only'` when not specified. + * @param request - The incoming request whose credentials authenticate Elasticsearch calls. + * - {@link KibanaRequest}: supports all routing options via `opts`. + * - {@link ScopeableRequest}: supports `'request-header'` routing or no routing (origin-only). + * - {@link ScopeableUrlRequest}: additionally supports `'space'` routing (space id extracted from URL). + * @param opts - Optional {@link AsScopedOptions} to configure CPS routing behavior. + * - 'request-header': Routes the request to the PRE specified in the `x-kbn-project-routing` header. + * - 'space': Routes the request to the NPRE configured for the current Kibana space. + * The client will route the request to the origin project if no options are provided. */ - asScoped( - request: ScopeableRequest, - opts?: OriginOnlyRouting | AllProjectsRouting - ): IScopedClusterClient; + asScoped(request: ScopeableUrlRequest, opts?: SpaceNPRERouting): IScopedClusterClient; } /** diff --git a/src/core/packages/elasticsearch/server/src/client/index.ts b/src/core/packages/elasticsearch/server/src/client/index.ts index e7611d2522a1a..ef6f22c8e7654 100644 --- a/src/core/packages/elasticsearch/server/src/client/index.ts +++ b/src/core/packages/elasticsearch/server/src/client/index.ts @@ -12,9 +12,8 @@ export type { IClusterClient, ICustomClusterClient, AsScopedOptions, - OriginOnlyRouting, SpaceNPRERouting, - AllProjectsRouting, + RequestHeaderRouting, } from './cluster_client'; export type { ScopeableRequest, FakeRequest, UrlRequest, ScopeableUrlRequest } from './types'; export type { IScopedClusterClient } from './scoped_cluster_client'; diff --git a/src/platform/packages/private/kbn-cps-common/index.ts b/src/platform/packages/private/kbn-cps-common/index.ts index 33ecbc3ffaaf8..7e429f4f317a6 100644 --- a/src/platform/packages/private/kbn-cps-common/index.ts +++ b/src/platform/packages/private/kbn-cps-common/index.ts @@ -7,6 +7,6 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export { PROJECT_ROUTING } from './src/common/constants'; +export { PROJECT_ROUTING, KBN_PROJECT_ROUTING_HEADER } from './src/common/constants'; export type { ProjectRoutingValue } from './src/common/constants'; export { getSpaceDefaultNpreName } from './src/common/get_space_default_npre_name'; diff --git a/src/platform/packages/private/kbn-cps-common/src/common/constants.ts b/src/platform/packages/private/kbn-cps-common/src/common/constants.ts index 821a622520f97..22719d08ab714 100644 --- a/src/platform/packages/private/kbn-cps-common/src/common/constants.ts +++ b/src/platform/packages/private/kbn-cps-common/src/common/constants.ts @@ -19,3 +19,11 @@ export const PROJECT_ROUTING = { } as const; export type ProjectRoutingValue = (typeof PROJECT_ROUTING)[keyof typeof PROJECT_ROUTING]; + +/** + * HTTP header name used to propagate the CPS `project_routing` value from the browser to + * Kibana server-side route handlers. App developers set this header on outgoing HTTP requests + * (via the Kibana HTTP client) and opt in on the server by calling + * `esClient.asScoped(request, { projectRouting: 'request-header' })`. + */ +export const KBN_PROJECT_ROUTING_HEADER = 'x-kbn-project-routing' as const; diff --git a/src/platform/packages/shared/kbn-cps-server-utils/index.ts b/src/platform/packages/shared/kbn-cps-server-utils/index.ts index 5c9ef617073d7..c613a82c00be3 100644 --- a/src/platform/packages/shared/kbn-cps-server-utils/index.ts +++ b/src/platform/packages/shared/kbn-cps-server-utils/index.ts @@ -8,4 +8,8 @@ */ export { getSpaceNPRE } from './src/get_space_npre'; -export { PROJECT_ROUTING_ORIGIN, PROJECT_ROUTING_ALL } from './src/constants'; +export { + PROJECT_ROUTING_ORIGIN, + PROJECT_ROUTING_ALL, + KBN_PROJECT_ROUTING_HEADER, +} from './src/constants'; diff --git a/src/platform/packages/shared/kbn-cps-server-utils/src/constants.ts b/src/platform/packages/shared/kbn-cps-server-utils/src/constants.ts index 98f2138d0450f..52cf67cb9cb85 100644 --- a/src/platform/packages/shared/kbn-cps-server-utils/src/constants.ts +++ b/src/platform/packages/shared/kbn-cps-server-utils/src/constants.ts @@ -16,3 +16,11 @@ export const PROJECT_ROUTING_ORIGIN = '_alias:_origin'; * Project routing expression that allows requests across all projects. */ export const PROJECT_ROUTING_ALL = '_alias:*'; + +/** + * HTTP header name used to propagate the CPS `project_routing` value from the browser to + * Kibana server-side route handlers. App developers set this header on outgoing HTTP requests + * (via the Kibana HTTP client) and opt in on the server by calling + * `esClient.asScoped(request, { projectRouting: 'request-header' })`. + */ +export const KBN_PROJECT_ROUTING_HEADER = 'x-kbn-project-routing' as const; diff --git a/src/platform/packages/shared/kbn-cps-utils/index.ts b/src/platform/packages/shared/kbn-cps-utils/index.ts index 1aa7b4fac6739..08ad394592246 100644 --- a/src/platform/packages/shared/kbn-cps-utils/index.ts +++ b/src/platform/packages/shared/kbn-cps-utils/index.ts @@ -22,5 +22,5 @@ export { ProjectPicker } from './components/project_picker'; export { ProjectPickerContent } from './components/project_picker_content'; export { ProjectPickerContainer } from './components/project_picker_container'; export { useFetchProjects } from './components/use_fetch_projects'; -export { PROJECT_ROUTING } from '@kbn/cps-common'; +export { KBN_PROJECT_ROUTING_HEADER, PROJECT_ROUTING } from '@kbn/cps-common'; export { ProjectRoutingAccess } from './types'; diff --git a/src/platform/packages/shared/kbn-search-types/src/types.ts b/src/platform/packages/shared/kbn-search-types/src/types.ts index d2074cab8c011..d52f912b685cf 100644 --- a/src/platform/packages/shared/kbn-search-types/src/types.ts +++ b/src/platform/packages/shared/kbn-search-types/src/types.ts @@ -147,5 +147,4 @@ export type ISearchOptionsSerializable = Pick< | 'retrieveResults' | 'executionContext' | 'stream' - | 'projectRouting' >; diff --git a/src/platform/plugins/shared/data/public/search/search_interceptor/search_interceptor.test.ts b/src/platform/plugins/shared/data/public/search/search_interceptor/search_interceptor.test.ts index 11a9adc2768fd..84499a5fc7f6e 100644 --- a/src/platform/plugins/shared/data/public/search/search_interceptor/search_interceptor.test.ts +++ b/src/platform/plugins/shared/data/public/search/search_interceptor/search_interceptor.test.ts @@ -32,6 +32,7 @@ import type { Start as InspectorStart } from '@kbn/inspector-plugin/public'; import { SearchTimeoutError, TimeoutErrorMode } from './timeout_error'; import { SearchSessionIncompleteWarning } from './search_session_incomplete_warning'; import { getMockSearchConfig } from '../../../config.mock'; +import { KBN_PROJECT_ROUTING_HEADER } from '@kbn/cps-utils'; import type { ICPSManager } from '@kbn/cps-utils'; jest.mock('./create_request_hash', () => { @@ -2274,8 +2275,13 @@ describe('SearchInterceptor', () => { mockCoreSetup.http.post.mockResolvedValue(getMockSearchResponse()); }); + const getProjectRoutingHeader = (call: unknown[]) => { + const requestOptions = (call as unknown as [string, HttpFetchOptions])[1]; + return requestOptions.headers?.[KBN_PROJECT_ROUTING_HEADER]; + }; + describe('ESQL_ASYNC_SEARCH_STRATEGY', () => { - test('User passes "_alias:*" with global "_alias:_origin" - sends to ES', async () => { + test('User passes "_alias:*" with global "_alias:_origin" - header set to user value', async () => { searchInterceptor = getSearchInterceptor({ getCPSManager: jest.fn().mockReturnValue(createMockCPSManager('_alias:_origin')), }); @@ -2287,14 +2293,10 @@ describe('SearchInterceptor', () => { ) .toPromise(); - const requestOptions = ( - mockCoreSetup.http.post.mock.calls[0] as unknown as [string, HttpFetchOptions] - )[1]; - const requestBody = JSON.parse(requestOptions.body as string); - expect(requestBody.projectRouting).toBe('_alias:*'); + expect(getProjectRoutingHeader(mockCoreSetup.http.post.mock.calls[0])).toBe('_alias:*'); }); - test('User passes "_alias:*" with global "_alias:*" - sends to ES', async () => { + test('User passes "_alias:*" with global "_alias:*" - header set to user value', async () => { searchInterceptor = getSearchInterceptor({ getCPSManager: jest.fn().mockReturnValue(createMockCPSManager('_alias:*')), }); @@ -2306,14 +2308,10 @@ describe('SearchInterceptor', () => { ) .toPromise(); - const requestOptions = ( - mockCoreSetup.http.post.mock.calls[0] as unknown as [string, HttpFetchOptions] - )[1]; - const requestBody = JSON.parse(requestOptions.body as string); - expect(requestBody.projectRouting).toBe('_alias:*'); + expect(getProjectRoutingHeader(mockCoreSetup.http.post.mock.calls[0])).toBe('_alias:*'); }); - test('User passes "_alias:_origin" with global "_alias:_origin" - sends to ES', async () => { + test('User passes "_alias:_origin" with global "_alias:_origin" - header set to user value', async () => { searchInterceptor = getSearchInterceptor({ getCPSManager: jest.fn().mockReturnValue(createMockCPSManager('_alias:_origin')), }); @@ -2325,14 +2323,12 @@ describe('SearchInterceptor', () => { ) .toPromise(); - const requestOptions = ( - mockCoreSetup.http.post.mock.calls[0] as unknown as [string, HttpFetchOptions] - )[1]; - const requestBody = JSON.parse(requestOptions.body as string); - expect(requestBody.projectRouting).toBe('_alias:_origin'); + expect(getProjectRoutingHeader(mockCoreSetup.http.post.mock.calls[0])).toBe( + '_alias:_origin' + ); }); - test('User passes "_alias:_origin" with global "_alias:*" - sends to ES', async () => { + test('User passes "_alias:_origin" with global "_alias:*" - header set to user value', async () => { searchInterceptor = getSearchInterceptor({ getCPSManager: jest.fn().mockReturnValue(createMockCPSManager('_alias:*')), }); @@ -2344,14 +2340,12 @@ describe('SearchInterceptor', () => { ) .toPromise(); - const requestOptions = ( - mockCoreSetup.http.post.mock.calls[0] as unknown as [string, HttpFetchOptions] - )[1]; - const requestBody = JSON.parse(requestOptions.body as string); - expect(requestBody.projectRouting).toBe('_alias:_origin'); + expect(getProjectRoutingHeader(mockCoreSetup.http.post.mock.calls[0])).toBe( + '_alias:_origin' + ); }); - test('User passes nothing with global "_alias:_origin" - sends global to ES', async () => { + test('User passes nothing with global "_alias:_origin" - header set to global value', async () => { searchInterceptor = getSearchInterceptor({ getCPSManager: jest.fn().mockReturnValue(createMockCPSManager('_alias:_origin')), }); @@ -2360,14 +2354,12 @@ describe('SearchInterceptor', () => { .search({ params: {} }, { strategy: ESQL_ASYNC_SEARCH_STRATEGY }) .toPromise(); - const requestOptions = ( - mockCoreSetup.http.post.mock.calls[0] as unknown as [string, HttpFetchOptions] - )[1]; - const requestBody = JSON.parse(requestOptions.body as string); - expect(requestBody.projectRouting).toBe('_alias:_origin'); + expect(getProjectRoutingHeader(mockCoreSetup.http.post.mock.calls[0])).toBe( + '_alias:_origin' + ); }); - test('User passes nothing with global "_alias:*" - sends global to ES', async () => { + test('User passes nothing with global "_alias:*" - header set to global value', async () => { searchInterceptor = getSearchInterceptor({ getCPSManager: jest.fn().mockReturnValue(createMockCPSManager('_alias:*')), }); @@ -2376,14 +2368,10 @@ describe('SearchInterceptor', () => { .search({ params: {} }, { strategy: ESQL_ASYNC_SEARCH_STRATEGY }) .toPromise(); - const requestOptions = ( - mockCoreSetup.http.post.mock.calls[0] as unknown as [string, HttpFetchOptions] - )[1]; - const requestBody = JSON.parse(requestOptions.body as string); - expect(requestBody.projectRouting).toBe('_alias:*'); + expect(getProjectRoutingHeader(mockCoreSetup.http.post.mock.calls[0])).toBe('_alias:*'); }); - test('User passes nothing with global undefined - does not send to ES', async () => { + test('User passes nothing with global undefined - header not set', async () => { searchInterceptor = getSearchInterceptor({ getCPSManager: jest.fn().mockReturnValue(createMockCPSManager(undefined)), }); @@ -2392,14 +2380,10 @@ describe('SearchInterceptor', () => { .search({ params: {} }, { strategy: ESQL_ASYNC_SEARCH_STRATEGY }) .toPromise(); - const requestOptions = ( - mockCoreSetup.http.post.mock.calls[0] as unknown as [string, HttpFetchOptions] - )[1]; - const requestBody = JSON.parse(requestOptions.body as string); - expect(requestBody.projectRouting).toBeUndefined(); + expect(getProjectRoutingHeader(mockCoreSetup.http.post.mock.calls[0])).toBeUndefined(); }); - test('CPS unavailable - does not send to ES', async () => { + test('CPS unavailable - header not set', async () => { searchInterceptor = getSearchInterceptor({ getCPSManager: undefined }); await searchInterceptor @@ -2409,16 +2393,12 @@ describe('SearchInterceptor', () => { ) .toPromise(); - const requestOptions = ( - mockCoreSetup.http.post.mock.calls[0] as unknown as [string, HttpFetchOptions] - )[1]; - const requestBody = JSON.parse(requestOptions.body as string); - expect(requestBody.projectRouting).toBeUndefined(); + expect(getProjectRoutingHeader(mockCoreSetup.http.post.mock.calls[0])).toBeUndefined(); }); }); describe('ENHANCED_ES_SEARCH_STRATEGY', () => { - test('User passes "_alias:*" with global "_alias:_origin" - sends to ES', async () => { + test('User passes "_alias:*" with global "_alias:_origin" - header set to user value', async () => { searchInterceptor = getSearchInterceptor({ getCPSManager: jest.fn().mockReturnValue(createMockCPSManager('_alias:_origin')), }); @@ -2430,14 +2410,10 @@ describe('SearchInterceptor', () => { ) .toPromise(); - const requestOptions = ( - mockCoreSetup.http.post.mock.calls[0] as unknown as [string, HttpFetchOptions] - )[1]; - const requestBody = JSON.parse(requestOptions.body as string); - expect(requestBody.projectRouting).toBe('_alias:*'); + expect(getProjectRoutingHeader(mockCoreSetup.http.post.mock.calls[0])).toBe('_alias:*'); }); - test('User passes "_alias:_origin" with global "_alias:*" - sends to ES', async () => { + test('User passes "_alias:_origin" with global "_alias:*" - header set to user value', async () => { searchInterceptor = getSearchInterceptor({ getCPSManager: jest.fn().mockReturnValue(createMockCPSManager('_alias:*')), }); @@ -2449,14 +2425,12 @@ describe('SearchInterceptor', () => { ) .toPromise(); - const requestOptions = ( - mockCoreSetup.http.post.mock.calls[0] as unknown as [string, HttpFetchOptions] - )[1]; - const requestBody = JSON.parse(requestOptions.body as string); - expect(requestBody.projectRouting).toBe('_alias:_origin'); + expect(getProjectRoutingHeader(mockCoreSetup.http.post.mock.calls[0])).toBe( + '_alias:_origin' + ); }); - test('User passes nothing with global "_alias:_origin" - sends global to ES', async () => { + test('User passes nothing with global "_alias:_origin" - header set to global value', async () => { searchInterceptor = getSearchInterceptor({ getCPSManager: jest.fn().mockReturnValue(createMockCPSManager('_alias:_origin')), }); @@ -2465,14 +2439,12 @@ describe('SearchInterceptor', () => { .search({ params: { body: {} } }, { strategy: ENHANCED_ES_SEARCH_STRATEGY }) .toPromise(); - const requestOptions = ( - mockCoreSetup.http.post.mock.calls[0] as unknown as [string, HttpFetchOptions] - )[1]; - const requestBody = JSON.parse(requestOptions.body as string); - expect(requestBody.projectRouting).toBe('_alias:_origin'); + expect(getProjectRoutingHeader(mockCoreSetup.http.post.mock.calls[0])).toBe( + '_alias:_origin' + ); }); - test('CPS unavailable - does not send to ES', async () => { + test('CPS unavailable - header not set', async () => { searchInterceptor = getSearchInterceptor({ getCPSManager: undefined }); await searchInterceptor @@ -2482,11 +2454,7 @@ describe('SearchInterceptor', () => { ) .toPromise(); - const requestOptions = ( - mockCoreSetup.http.post.mock.calls[0] as unknown as [string, HttpFetchOptions] - )[1]; - const requestBody = JSON.parse(requestOptions.body as string); - expect(requestBody.projectRouting).toBeUndefined(); + expect(getProjectRoutingHeader(mockCoreSetup.http.post.mock.calls[0])).toBeUndefined(); }); }); }); diff --git a/src/platform/plugins/shared/data/public/search/search_interceptor/search_interceptor.ts b/src/platform/plugins/shared/data/public/search/search_interceptor/search_interceptor.ts index d356581f27694..603223a8f1fff 100644 --- a/src/platform/plugins/shared/data/public/search/search_interceptor/search_interceptor.ts +++ b/src/platform/plugins/shared/data/public/search/search_interceptor/search_interceptor.ts @@ -64,6 +64,7 @@ import type { } from '@kbn/search-types'; import { createEsError, isEsError, renderSearchError } from '@kbn/search-errors'; import { AbortReason, defaultFreeze } from '@kbn/kibana-utils-plugin/common'; +import { KBN_PROJECT_ROUTING_HEADER } from '@kbn/cps-utils'; import type { ICPSManager } from '@kbn/cps-utils'; import { EVENT_TYPE_DATA_SEARCH_TIMEOUT, @@ -285,9 +286,6 @@ export class SearchInterceptor { if (combined.executionContext !== undefined) { serializableOptions.executionContext = combined.executionContext; } - if (combined.projectRouting !== undefined) { - serializableOptions.projectRouting = combined.projectRouting; - } return serializableOptions; } @@ -479,7 +477,7 @@ export class SearchInterceptor { { params, ...request }: IKibanaSearchRequest, options?: ISearchOptions ): Promise { - const { abortSignal } = options || {}; + const { abortSignal, projectRouting } = options || {}; const requestHash = params ? createRequestHashForBackgroundSearches(params) : undefined; @@ -496,6 +494,9 @@ export class SearchInterceptor { version: '1', signal: abortSignal, context: this.deps.executionContext.withGlobalContext(executionContext), + ...(projectRouting != null && { + headers: { [KBN_PROJECT_ROUTING_HEADER]: projectRouting }, + }), body: JSON.stringify({ ...{ ...request, params: paramsToUse }, ...searchOptions, diff --git a/src/platform/plugins/shared/data/server/search/routes/search.ts b/src/platform/plugins/shared/data/server/search/routes/search.ts index 892cfa6112edb..ce241ebc4dbcd 100644 --- a/src/platform/plugins/shared/data/server/search/routes/search.ts +++ b/src/platform/plugins/shared/data/server/search/routes/search.ts @@ -55,7 +55,6 @@ export function registerSearchRoute( retrieveResults: schema.maybe(schema.boolean()), stream: schema.maybe(schema.boolean()), requestHash: schema.maybe(schema.string()), - projectRouting: schema.maybe(schema.string()), }, { unknowns: 'allow' } ), @@ -71,7 +70,6 @@ export function registerSearchRoute( retrieveResults, stream, requestHash, - projectRouting, ...searchRequest } = request.body; const { strategy, id } = request.params; @@ -106,7 +104,6 @@ export function registerSearchRoute( retrieveResults, stream, requestHash, - projectRouting, } ) .pipe(first()) diff --git a/src/platform/plugins/shared/data/server/search/search_service.test.ts b/src/platform/plugins/shared/data/server/search/search_service.test.ts index e56ff93ce59d4..820188fc09c4d 100644 --- a/src/platform/plugins/shared/data/server/search/search_service.test.ts +++ b/src/platform/plugins/shared/data/server/search/search_service.test.ts @@ -151,35 +151,13 @@ describe('Search service', () => { expect(asScopedSpy).toHaveBeenCalledWith(request, { projectRouting: 'space' }); }); - it('calls elasticsearch.client.asScoped with request and projectRouting: "all" when opts.projectRouting is "all"', () => { - const asScopedSpy = mockCoreStart.elasticsearch.client.asScoped as jest.Mock; - asScopedSpy.mockClear(); - - const request = {} as any; - searchPluginStart.asScoped(request, { projectRouting: 'all' }); - - expect(asScopedSpy).toHaveBeenCalledTimes(1); - expect(asScopedSpy).toHaveBeenCalledWith(request, { projectRouting: 'all' }); - }); - - it('calls elasticsearch.client.asScoped with request and projectRouting: "origin-only" when opts.projectRouting is "origin-only"', () => { - const asScopedSpy = mockCoreStart.elasticsearch.client.asScoped as jest.Mock; - asScopedSpy.mockClear(); - - const request = {} as any; - searchPluginStart.asScoped(request, { projectRouting: 'origin-only' }); - - expect(asScopedSpy).toHaveBeenCalledTimes(1); - expect(asScopedSpy).toHaveBeenCalledWith(request, { projectRouting: 'origin-only' }); - }); - it('returns a scoped client that can search when called with opts', async () => { const asScopedSpy = mockCoreStart.elasticsearch.client.asScoped as jest.Mock; asScopedSpy.mockClear(); const request = {} as any; const scopedClient = searchPluginStart.asScoped(request, { - projectRouting: 'origin-only', + projectRouting: 'request-header', }); expect(scopedClient).toHaveProperty('search'); diff --git a/src/platform/plugins/shared/data/server/search/search_service.ts b/src/platform/plugins/shared/data/server/search/search_service.ts index 3ac40703cb863..155bbbe1fdcce 100644 --- a/src/platform/plugins/shared/data/server/search/search_service.ts +++ b/src/platform/plugins/shared/data/server/search/search_service.ts @@ -14,7 +14,6 @@ import moment from 'moment'; import type { CoreSetup, CoreStart, - IClusterClient, KibanaRequest, Logger, PluginInitializerContext, @@ -292,11 +291,9 @@ export class SearchService { asScoped: this.asScoped, searchSource: { asScoped: async (request: KibanaRequest, opts?: AsScopedOptions) => { - const esClient = this.createScopedEsClient({ - client: elasticsearch.client, - request, - opts, - }); + const esClient = opts + ? elasticsearch.client.asScoped(request, opts) + : elasticsearch.client.asScoped(request); const savedObjectsClient = savedObjects.getScopedClient(request); const scopedIndexPatterns = await indexPatterns.dataViewsServiceFactory( @@ -546,7 +543,9 @@ export class SearchService { const deps = { searchSessionsClient, savedObjectsClient, - esClient: this.createScopedEsClient({ client: elasticsearch.client, request, opts }), + esClient: opts + ? elasticsearch.client.asScoped(request, opts) + : elasticsearch.client.asScoped(request), uiSettingsClient: new CachedUiSettingsClient( uiSettings.asScopedToClient(savedObjectsClient) ), @@ -576,22 +575,4 @@ export class SearchService { }; }; }; - - private createScopedEsClient = ({ - client, - request, - opts, - }: { - client: IClusterClient; - request: KibanaRequest; - opts?: AsScopedOptions; - }) => { - return opts?.projectRouting === 'space' - ? client.asScoped(request, { projectRouting: 'space' }) - : opts?.projectRouting === 'all' - ? client.asScoped(request, { projectRouting: 'all' }) - : opts?.projectRouting === 'origin-only' - ? client.asScoped(request, { projectRouting: 'origin-only' }) - : client.asScoped(request); - }; } diff --git a/src/platform/plugins/shared/data/server/search/strategies/common/async_utils.ts b/src/platform/plugins/shared/data/server/search/strategies/common/async_utils.ts index 5d1d8be8cc8fa..359a566f4555b 100644 --- a/src/platform/plugins/shared/data/server/search/strategies/common/async_utils.ts +++ b/src/platform/plugins/shared/data/server/search/strategies/common/async_utils.ts @@ -29,7 +29,7 @@ export function getCommonDefaultAsyncSubmitParams( ): Pick< AsyncSearchSubmitRequest, 'keep_alive' | 'wait_for_completion_timeout' | 'keep_on_completion' -> & { project_routing?: string } { +> { const useSearchSessions = config.sessions.enabled && !!options.sessionId && !overrides?.disableSearchSessions; const keepAlive = @@ -44,8 +44,6 @@ export function getCommonDefaultAsyncSubmitParams( keep_on_completion: useSearchSessions, // The initial keepalive is as defined in defaultExpiration if search sessions are used or 1m otherwise. keep_alive: keepAlive, - // Pass project routing for CPS if available - ...(options.projectRouting !== undefined && { project_routing: options.projectRouting }), }; } diff --git a/src/platform/plugins/shared/data/server/search/strategies/es_search/es_search_strategy.ts b/src/platform/plugins/shared/data/server/search/strategies/es_search/es_search_strategy.ts index f68ec28ab2de1..11445e32b8bbd 100644 --- a/src/platform/plugins/shared/data/server/search/strategies/es_search/es_search_strategy.ts +++ b/src/platform/plugins/shared/data/server/search/strategies/es_search/es_search_strategy.ts @@ -70,7 +70,6 @@ export const esSearchStrategyProvider = ( ...defaults, ...getShardTimeout(config), ...(terminateAfter ? { terminate_after: terminateAfter } : {}), - ...(options.projectRouting !== undefined && { project_routing: options.projectRouting }), ...requestParams, }; const { body, meta } = await esClient.asCurrentUser.search(params, { diff --git a/src/platform/plugins/shared/data/server/search/strategies/ese_search/request_utils.ts b/src/platform/plugins/shared/data/server/search/strategies/ese_search/request_utils.ts index 50e6899b15574..0d0a9db666bca 100644 --- a/src/platform/plugins/shared/data/server/search/strategies/ese_search/request_utils.ts +++ b/src/platform/plugins/shared/data/server/search/strategies/ese_search/request_utils.ts @@ -49,7 +49,7 @@ export async function getDefaultAsyncSubmitParams( | 'ignore_unavailable' | 'track_total_hits' | 'keep_on_completion' - > & { project_routing?: string } + > > { return { // TODO: adjust for partial results