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..7c0643bac5dba 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'; @@ -319,7 +320,7 @@ describe('ClusterClient', () => { }); describe('CPS routing', () => { - it("injects the space NPRE when projectRouting is 'space'", () => { + it("injects the space NPRE when projectRouting is 'space-npre'", () => { // asScoped().asCurrentUser goes through createTransport, so capture onRequest from there. const onRequest = captureTransportOnRequest(); @@ -334,7 +335,7 @@ describe('ClusterClient', () => { }); const request = httpServerMock.createKibanaRequest({ path: '/s/my-space/app/discover' }); - client = clusterClient.asScoped(request, { projectRouting: 'space' }).asCurrentUser; + client = clusterClient.asScoped(request, { projectRouting: 'space-npre' }).asCurrentUser; const params = makeSearchParams(); onRequest.get()({} as never, params, {}, logger); @@ -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 @@ -1383,10 +1412,12 @@ describe('ClusterClient', () => { onRequestHandlerFactory: mockOnRequestHandlerFactory, }); - // Even when the scoped client is created with 'space' routing, asSecondaryAuthUser + // Even when the scoped client is created with 'space-npre' 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-npre', + }).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..9f83b23e6e5e9 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 @@ -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,23 @@ import type { AgentFactoryProvider } from './agent_manager'; const noop = () => undefined; +/** + * Discriminated union of routing options passed to {@link OnRequestHandlerFactory}. + * Each variant carries exactly the data needed for that routing mode. + * @internal + */ +export type FactoryRoutingOpts = { logger: Logger } & ( + | { projectRouting: 'origin-only' } + | { projectRouting: 'all' } + | { projectRouting: 'space-npre'; request: ScopeableUrlRequest } + | { projectRouting: 'request-header'; request: ScopeableRequest } +); + /** * 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 { @@ -128,24 +136,29 @@ 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: ScopeableRequest, opts: RequestHeaderRouting): IScopedClusterClient; + asScoped(request: ScopeableRequest): IScopedClusterClient; + asScoped(request: ScopeableRequest, opts?: AsScopedOptions) { const createScopedClient = () => { const scopedHeaders = this.getScopedHeaders(request); - const { projectRouting } = opts; + let factoryOpts: FactoryRoutingOpts; + if (opts?.projectRouting === 'space-npre') { + factoryOpts = { + projectRouting: 'space-npre', + request: request as ScopeableUrlRequest, + logger: this.logger, + }; + } else if (opts?.projectRouting === 'request-header') { + factoryOpts = { projectRouting: 'request-header', request, logger: this.logger }; + } else { + factoryOpts = { projectRouting: 'origin-only', logger: this.logger }; + } 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(factoryOpts), 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..83a52f291cdc6 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,12 @@ 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, + PROJECT_ROUTING_ALL, + KBN_PROJECT_ROUTING_HEADER, + getSpaceNPRE, +} from '@kbn/cps-server-utils'; import { loggerMock } from '@kbn/logging-mocks'; import { getRequestHandlerFactory } from './cps_request_handler_factory'; @@ -74,11 +79,11 @@ describe('getRequestHandlerFactory', () => { }); }); - describe("projectRouting: 'space'", () => { + describe("projectRouting: 'space-npre'", () => { it('injects the space NPRE derived from a KibanaRequest', () => { const factory = getRequestHandlerFactory(true); const request = httpServerMock.createKibanaRequest({ path: '/s/my-space/app/discover' }); - const handler = factory({ projectRouting: request, logger: mockLogger }); + const handler = factory({ projectRouting: 'space-npre', request, logger: mockLogger }); const params = makeSearchParams(); handler({ scoped: true }, params, {}, mockLogger); @@ -88,4 +93,58 @@ describe('getRequestHandlerFactory', () => { ); }); }); + + 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).toBe('_alias:*'); + }); + + it('falls back to PROJECT_ROUTING_ORIGIN when the header is absent', () => { + const factory = getRequestHandlerFactory(true); + 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_ORIGIN); + }); + + it('takes the first value when the header is an array', () => { + const factory = getRequestHandlerFactory(true); + // 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('_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..3ffea58b8f95b 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,12 @@ * 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, + PROJECT_ROUTING_ALL, + KBN_PROJECT_ROUTING_HEADER, +} from '@kbn/cps-server-utils'; import type { OnRequestHandlerFactory } from '../cluster_client'; import { getCpsRequestHandler } from './cps_request_handler'; @@ -18,15 +23,19 @@ import { getCpsRequestHandler } from './cps_request_handler'; * @internal */ export function getRequestHandlerFactory(cpsEnabled: boolean): OnRequestHandlerFactory { - return ({ projectRouting, logger }) => { - switch (projectRouting) { + return (opts) => { + switch (opts.projectRouting) { case 'origin-only': - return getCpsRequestHandler(cpsEnabled, PROJECT_ROUTING_ORIGIN, logger); + return getCpsRequestHandler(cpsEnabled, PROJECT_ROUTING_ORIGIN, opts.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 getCpsRequestHandler(cpsEnabled, PROJECT_ROUTING_ALL, opts.logger); + case 'space-npre': + return getCpsRequestHandler(cpsEnabled, getSpaceNPRE(opts.request), opts.logger); + case 'request-header': { + 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); + } } }; } 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..f7b7bcb02db58 100644 --- a/src/core/packages/elasticsearch/server/src/client/cluster_client.ts +++ b/src/core/packages/elasticsearch/server/src/client/cluster_client.ts @@ -27,32 +27,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-npre'`: 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-npre' | 'request-header'; } /** @@ -62,16 +54,19 @@ export interface OriginOnlyRouting extends AsScopedOptions { * @public */ export interface SpaceNPRERouting extends AsScopedOptions { - projectRouting: 'space'; + projectRouting: 'space-npre'; } /** - * {@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'; } /** @@ -96,31 +91,23 @@ export interface IClusterClient { * 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'`. + * In CPS-enabled Serverless environments, the request will be routed to the origin project by default. + * @param request - The incoming {@link ScopeableRequest | request} whose credentials are used + * to authenticate Elasticsearch calls. */ - asScoped(request: ScopeableUrlRequest, opts: SpaceNPRERouting): IScopedClusterClient; + asScoped(request: ScopeableRequest): IScopedClusterClient; /** * Creates a {@link IScopedClusterClient | scoped cluster client} bound to the given request, * forwarding the request's authentication headers to Elasticsearch. + * Routes the request according to the options provided. * - * 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 + * @param request - The incoming {@link ScopeableUrlRequest | 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 opts - {@link AsScopedOptions | options} to configure CPS routing behavior: + * - 'space-npre': Routes the request to the NPRE configured for the current Kibana space. The space id is extracted from the request URL. + * - 'request-header': Routes the request to the PRE specified in the `x-kbn-project-routing` header. */ - asScoped( - request: ScopeableRequest, - opts?: OriginOnlyRouting | AllProjectsRouting - ): IScopedClusterClient; + asScoped(request: ScopeableUrlRequest, opts: AsScopedOptions): 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/core/packages/elasticsearch/server/src/client/types.ts b/src/core/packages/elasticsearch/server/src/client/types.ts index bbd294ea97f36..094c0e118b436 100644 --- a/src/core/packages/elasticsearch/server/src/client/types.ts +++ b/src/core/packages/elasticsearch/server/src/client/types.ts @@ -19,7 +19,7 @@ export interface FakeRequest { } /** - * A minimal synthetic request for space-level CPS routing (`projectRouting: 'space'`) in + * A minimal synthetic request for space-level CPS routing (`projectRouting: 'space-npre'`) in * non-HTTP contexts - for example, background tasks or scheduled jobs - where no real * {@link KibanaRequest} is available. The space is derived from the URL pathname * (e.g. `/s//...`). @@ -43,7 +43,7 @@ export interface UrlRequest extends FakeRequest { export type ScopeableRequest = KibanaRequest | FakeRequest; /** - * A request that carries a URL, accepted by `asScoped` when `projectRouting: 'space'` is used. + * A request that carries a URL, accepted by `asScoped` when `projectRouting: 'space-npre'` is used. * * Covers both {@link KibanaRequest} (the typical caller from route handlers, whose URL is set by * the HTTP layer) and {@link UrlRequest} (a lightweight synthetic alternative for programmatic 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/plugins/shared/data/server/search/search_service.test.ts b/src/platform/plugins/shared/data/server/search/search_service.test.ts index e56ff93ce59d4..0cb7a199de4e7 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 @@ -129,7 +129,7 @@ describe('Search service', () => { }); describe('asScoped with opts', () => { - it('calls elasticsearch.client.asScoped with only request when opts is omitted', () => { + it('calls elasticsearch.client.asScoped with request-header routing when opts is omitted', () => { const asScopedSpy = mockCoreStart.elasticsearch.client.asScoped as jest.Mock; asScopedSpy.mockClear(); @@ -137,40 +137,18 @@ describe('Search service', () => { searchPluginStart.asScoped(request); expect(asScopedSpy).toHaveBeenCalledTimes(1); - expect(asScopedSpy).toHaveBeenCalledWith(request); + expect(asScopedSpy).toHaveBeenCalledWith(request, { projectRouting: 'request-header' }); }); - it('calls elasticsearch.client.asScoped with request and projectRouting: "space" when opts.projectRouting is "space"', () => { + it('calls elasticsearch.client.asScoped with request and projectRouting: "space-npre" when opts.projectRouting is "space-npre"', () => { const asScopedSpy = mockCoreStart.elasticsearch.client.asScoped as jest.Mock; asScopedSpy.mockClear(); const request = { url: new URL('https://kibana/s/my-space') } as any; - searchPluginStart.asScoped(request, { projectRouting: 'space' }); + searchPluginStart.asScoped(request, { projectRouting: 'space-npre' }); expect(asScopedSpy).toHaveBeenCalledTimes(1); - 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' }); + expect(asScopedSpy).toHaveBeenCalledWith(request, { projectRouting: 'space-npre' }); }); it('returns a scoped client that can search when called with opts', async () => { @@ -179,7 +157,7 @@ describe('Search service', () => { 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..fc2679bcb5b0b 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,10 @@ export class SearchService { asScoped: this.asScoped, searchSource: { asScoped: async (request: KibanaRequest, opts?: AsScopedOptions) => { - const esClient = this.createScopedEsClient({ - client: elasticsearch.client, + const esClient = elasticsearch.client.asScoped( request, - opts, - }); + opts ?? { projectRouting: 'request-header' } + ); const savedObjectsClient = savedObjects.getScopedClient(request); const scopedIndexPatterns = await indexPatterns.dataViewsServiceFactory( @@ -546,7 +544,10 @@ export class SearchService { const deps = { searchSessionsClient, savedObjectsClient, - esClient: this.createScopedEsClient({ client: elasticsearch.client, request, opts }), + esClient: elasticsearch.client.asScoped( + request, + opts ?? { projectRouting: 'request-header' } + ), uiSettingsClient: new CachedUiSettingsClient( uiSettings.asScopedToClient(savedObjectsClient) ), @@ -576,22 +577,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/x-pack/platform/plugins/shared/alerting/server/task_runner/get_executor_services.test.ts b/x-pack/platform/plugins/shared/alerting/server/task_runner/get_executor_services.test.ts index 180c7e4bc4cd3..2341bf197ad30 100644 --- a/x-pack/platform/plugins/shared/alerting/server/task_runner/get_executor_services.test.ts +++ b/x-pack/platform/plugins/shared/alerting/server/task_runner/get_executor_services.test.ts @@ -30,7 +30,7 @@ jest.mock('../lib/wrap_async_search_client', () => ({ wrapAsyncSearchClient: jest.fn().mockReturnValue({ search: jest.fn(), getMetrics: jest.fn() }), })); -const projectRouting: SpaceNPRERouting = { projectRouting: 'space' }; +const projectRouting: SpaceNPRERouting = { projectRouting: 'space-npre' }; function createMockContext(): jest.Mocked { const elasticsearch = elasticsearchServiceMock.createInternalStart(); diff --git a/x-pack/platform/plugins/shared/alerting/server/task_runner/get_executor_services.ts b/x-pack/platform/plugins/shared/alerting/server/task_runner/get_executor_services.ts index ee439a7c64a7a..b71587f91bd14 100644 --- a/x-pack/platform/plugins/shared/alerting/server/task_runner/get_executor_services.ts +++ b/x-pack/platform/plugins/shared/alerting/server/task_runner/get_executor_services.ts @@ -55,9 +55,9 @@ export interface ExecutorServices { ) => AsyncSearchClient; } -// Default project routing for rules when CPS is enabled is 'space' +// Default project routing for rules when CPS is enabled is 'space-npre' // If there is no default routing defined for the space, it falls back to 'all' when CPS is enabled -const PROJECT_ROUTING_FOR_RULES = 'space'; +const PROJECT_ROUTING_FOR_RULES = 'space-npre'; const projectRouting: SpaceNPRERouting = { projectRouting: PROJECT_ROUTING_FOR_RULES, };