Skip to content

[CPS] Per-client CPS routing via OnRequestHandlerFactory#254186

Merged
gsoldevila merged 17 commits into
elastic:mainfrom
gsoldevila:per-client-cps-routing
Feb 23, 2026
Merged

[CPS] Per-client CPS routing via OnRequestHandlerFactory#254186
gsoldevila merged 17 commits into
elastic:mainfrom
gsoldevila:per-client-cps-routing

Conversation

@gsoldevila
Copy link
Copy Markdown
Member

@gsoldevila gsoldevila commented Feb 20, 2026

Part of https://github.com/elastic/kibana-team/issues/2829

Summary

Builds on #254085 (@kbn/cps-server-utils) to allow callers of ClusterClient.asScoped() to control how the CPS project_routing header is injected on a per-client basis.

asScoped options

ClusterClient.asScoped(request, opts?) now accepts a typed opts argument. Three named specializations of AsScopedOptions are provided, each narrowing the projectRouting field:

  • OriginOnlyRouting (projectRouting: 'origin-only', default) — preserves the existing behavior, injects _alias:_origin.
  • AllProjectsRouting (projectRouting: 'all') — injects _alias:*, routing across all CPS-connected projects.
  • SpaceNPRERouting (projectRouting: 'space') — derives the NPRE from the request URL and injects kibana_space_${spaceId}_default.

Using SpaceNPRERouting requires passing a ScopeableUrlRequest (KibanaRequest | UrlRequest) as the first argument. This is enforced by an overload on asScoped, so passing an incompatible FakeRequest (without a url) is a compile-time error.

New request types

  • UrlRequest — a minimal FakeRequest with a url: URL property, for use in non-HTTP contexts (e.g. background tasks) where no real KibanaRequest is available but space-level routing is needed.
  • ScopeableUrlRequestKibanaRequest | UrlRequest, the type accepted by the SpaceNPRERouting overload of asScoped. Route handlers can pass their incoming KibanaRequest directly.

OnRequestHandlerFactory

A mandatory factory (OnRequestHandlerFactory) is passed to the ClusterClient constructor instead of a bare OnRequestHandler. The factory is called lazily per scope, producing the right handler based on the routing mode. The projectRouting field is a discriminated union: a ScopeableUrlRequest signals space routing, while 'origin-only' and 'all' cover the remaining modes.

getSpaceNPRE in @kbn/cps-server-utils

Broadened to accept string | { url: URL } instead of string | KibanaRequest, removing the coupling to KibanaRequest. Both KibanaRequest and UrlRequest satisfy this structural type. A defensive runtime guard is retained (with a documented @throws) to protect against JavaScript callers bypassing the type system.

Known limitation

getSpaceNPRE assumes the server base path is /. If Kibana were deployed with a custom server.basePath the space segment would not be stripped before matching, and the function would always resolve to the default space. CPS is a Serverless-only feature and Serverless deployments always run at the root path, so this is not a concern in practice today. This assumption is documented in the JSDoc.

CLI setup client

cli_setup's ClusterClient is on-prem only; it uses getRequestHandlerFactory(false) (CPS disabled), which strips any project_routing header rather than no-oping.

Made with Cursor

@gsoldevila gsoldevila added Team:Core Platform Core services: plugins, logging, config, saved objects, http, ES client, i18n, etc t// release_note:skip Skip the PR/issue when compiling release notes labels Feb 20, 2026
@gsoldevila gsoldevila requested a review from a team as a code owner February 20, 2026 12:43
@gsoldevila gsoldevila added backport:skip This PR does not require backporting Team:Core Platform Core services: plugins, logging, config, saved objects, http, ES client, i18n, etc t// release_note:skip Skip the PR/issue when compiling release notes labels Feb 20, 2026
@elasticmachine
Copy link
Copy Markdown
Contributor

Pinging @elastic/kibana-core (Team:Core)

1 similar comment
@elasticmachine
Copy link
Copy Markdown
Contributor

Pinging @elastic/kibana-core (Team:Core)

/**
* The behavior for automatically injecting 'project_routing' into requests.
*/
searchRouting: 'origin-only' | 'space-default' | 'all';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the value of separating space-default and all? I guess the benefit is you can create a client that will always search with _alias:* by default?

Reason I'm asking is bc we were originally thinking of origin-only or auto where auto would take care of using space default when appropriate but implies possible linked project routing. It feels like space-default is the new auto.

Copy link
Copy Markdown
Member Author

@gsoldevila gsoldevila Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exactly, it gives you more flexibility.
If you think it can be confusing for devs I can get rid of it and keep space-default for now.
Wrt the naming, auto seems too much of a black box IMO, whereas space-default hints users that the routing strategy is configurable per-space.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it, how about origin-only, space or all, weakly held opinion.

Any way we go I think the public interface should document the tradeoffs very clearly. Assuming developers have no idea what CPS even is 😃

We should also document the behaviour of internal users in our public APIs.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated with ffdcb04

Copy link
Copy Markdown
Contributor

@jloleysens jloleysens left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work @gsoldevila overall looks good to me! Left a couple of qs I'd like to get your thoughts on before approving.

this.onRequest = onRequest;
this.onRequestHandlerFactory = onRequestHandlerFactory;

const internalUserOnRequest = onRequestHandlerFactory({ searchRouting: 'origin-only' });
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fmi, this means .asScoped().asInternalUser will default to _alias:_origin? Sounds good 👍🏻

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I basically made the onRequest handler configurable instead of opinionated.

@gsoldevila gsoldevila force-pushed the per-client-cps-routing branch from ffd1b2a to 358dd6a Compare February 20, 2026 14:09
Comment thread src/platform/packages/shared/kbn-cps-server-utils/src/get_space_npre.ts Outdated
@elasticmachine
Copy link
Copy Markdown
Contributor

⏳ Build in-progress, with failures

Failed CI Steps

History

Copy link
Copy Markdown
Contributor

@rudolf rudolf left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left some nits but I think we can discuss if we want to do these as follow-up, don't think it's worth blocking over it.

Comment thread src/core/packages/elasticsearch/server/src/client/cluster_client.ts Outdated
});

it('passes `onRequest` handler to `createTransport`', () => {
it('calls onRequestHandlerFactory with null request for asInternalUser', () => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: these tests assert a lot of the wiring of the requestHandlerFactory and createTransport which tie the tests to the architecture. I think it might be simpler and less fragile to test the behavior "a space scoped client injects space default npre into project_routing param" or "a child client created from a space scoped client injects space default npre into project_routing param"

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gsoldevila and others added 15 commits February 23, 2026 14:31
Introduce `OnRequestHandlerFactory` to `ClusterClient` so each client
(internal user, scoped) builds its own `OnRequestHandler` on demand.
`CpsRequestHandler` becomes a factory (`createHandler(projectRouting)`)
rather than exposing a pre-built handler. `ElasticsearchService` owns all
CPS routing logic and wires the factory. Constants are centralised in
`@kbn/cps-server-utils`.

Co-authored-by: Cursor <cursoragent@cursor.com>
- Convert CpsRequestHandler class to a simple getCpsRequestHandler function
  accepting cpsEnabled and projectRouting directly
- Introduce cps_request_handler_factory.ts with getRequestHandlerFactory,
  which encapsulates all routing-mode logic and returns an OnRequestHandlerFactory
- Remove this.cpsRequestHandler instance from ElasticsearchService; setup()
  now delegates to getRequestHandlerFactory(cpsEnabled) in a single line
- Update tests accordingly, adding a dedicated test file for the factory

Co-authored-by: Cursor <cursoragent@cursor.com>
- `getSpaceNPRE` now accepts `string | KibanaRequest` only (FakeRequest
  is no longer a valid input). A defensive runtime throw is raised when
  a KibanaRequest without a `url` is passed, guarding against JavaScript
  callers bypassing the type system.
- `OnRequestHandlerFactory` opts use a discriminated union: passing a
  KibanaRequest as `searchRouting` signals space-default routing, while
  the string literals 'origin-only' and 'all' cover the remaining modes.
  This removes the need for a separate `request` field and makes
  FakeRequest structurally invalid for space-default routing.
- `kbn-cps-server-utils` tsconfig updated: `@kbn/core-elasticsearch-server`
  replaced by `@kbn/core-http-server`.
- Tests and README updated accordingly.

Co-authored-by: Cursor <cursoragent@cursor.com>
… create_transport tests

When onRequest was made mandatory in configure_client.ts and
create_transport.ts, the corresponding test files were not updated.
Added the required onRequest mock to all affected call sites and
updated the stale assertions that expected `onRequest: undefined`.

Co-authored-by: Cursor <cursoragent@cursor.com>
Pass the required onRequest handler (strip-only, cpsEnabled=false) to the
three callers that were missed when onRequest became mandatory in
configureClient: the model-version test kit, the migrator test kit, and
the user-agent integration test.

Co-authored-by: Cursor <cursoragent@cursor.com>
- Rename searchRouting option 'space-default' to 'space'
- Rename scopeable_request.ts to types.ts and add new request types:
  - UrlRequest: a minimal FakeRequest with url: URL, for space-level CPS
    routing in non-HTTP contexts (background tasks, scheduled jobs)
  - ScopeableUrlRequest: KibanaRequest | UrlRequest, the accepted type
    for asScoped when searchRouting: 'space'
- Add typed AsScopedOptions specializations with self-documenting names:
  - OriginOnlyRouting (searchRouting: 'origin-only')
  - SpaceNPRE (searchRouting: 'space')
  - AllProjectsRouting (searchRouting: 'all')
- Overload IClusterClient.asScoped and ClusterClient.asScoped to
  enforce that a ScopeableUrlRequest is passed when using SpaceNPRE
- Broaden getSpaceNPRE to accept string | { url: URL } instead of
  string | KibanaRequest, removing the KibanaRequest coupling
- Export all new public types from the package root
- Improve TSDoc across all affected types and interfaces

Co-authored-by: Cursor <cursoragent@cursor.com>
Renames the `searchRouting` field in `AsScopedOptions` and all
related types, factory parameters, and destructuring sites to
`projectRouting`, which more accurately reflects that this controls
Elasticsearch `project_routing` injection rather than anything
search-specific.

Affected: AsScopedOptions, OriginOnlyRouting, SpaceNPRERouting,
AllProjectsRouting, OnRequestHandlerFactory, ClusterClient.asScoped,
getRequestHandlerFactory, and associated tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
- Initialize onRequestHandlerFactory in the ElasticsearchService
  constructor (non-CPS mode) so that clients created during preboot
  (before setup() runs) are correctly configured
- Update getSpaceNPRE test to match the updated error message

Co-authored-by: Cursor <cursoragent@cursor.com>
@gsoldevila gsoldevila force-pushed the per-client-cps-routing branch from 7b97226 to af1a2c0 Compare February 23, 2026 13:34
Copy link
Copy Markdown
Contributor

@jloleysens jloleysens left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for addressing my feedback! Let's see how projectRouting goes! Nice work @gsoldevila

@elasticmachine
Copy link
Copy Markdown
Contributor

💛 Build succeeded, but was flaky

Failed CI Steps

Test Failures

  • [job] [logs] Jest Integration Tests #2 / ui settings migrations migrates siem:* configs

Metrics [docs]

Public APIs missing comments

Total count of every public API that lacks a comment. Target amount is 0. Run node scripts/build_api_docs --plugin [yourplugin] --stats comments for more detailed information.

id before after diff
@kbn/core-elasticsearch-client-server-internal 26 32 +6
@kbn/core-elasticsearch-server 55 57 +2
@kbn/cps-server-utils 2 0 -2
total +6

Public APIs missing exports

Total count of every type that is part of your API that should be exported but is not. This will cause broken links in the API documentation system. Target amount is 0. Run node scripts/build_api_docs --plugin [yourplugin] --stats exports for more detailed information.

id before after diff
@kbn/core 975 981 +6
Unknown metric groups

API count

id before after diff
@kbn/core-elasticsearch-client-server-internal 35 41 +6
@kbn/core-elasticsearch-server 120 135 +15
@kbn/cps-server-utils 2 4 +2
total +23

History

@gsoldevila gsoldevila merged commit c01e7b9 into elastic:main Feb 23, 2026
13 checks passed
bhapas pushed a commit to bhapas/kibana that referenced this pull request Feb 25, 2026
)

Part of elastic/kibana-team#2829

## Summary

Builds on elastic#254085 (`@kbn/cps-server-utils`) to allow callers of
`ClusterClient.asScoped()` to control how the CPS `project_routing`
header is injected on a per-client basis.

### `asScoped` options

`ClusterClient.asScoped(request, opts?)` now accepts a typed `opts`
argument. Three named specializations of `AsScopedOptions` are provided,
each narrowing the `projectRouting` field:

- `OriginOnlyRouting` (`projectRouting: 'origin-only'`, default) —
preserves the existing behavior, injects `_alias:_origin`.
- `AllProjectsRouting` (`projectRouting: 'all'`) — injects `_alias:*`,
routing across all CPS-connected projects.
- `SpaceNPRERouting` (`projectRouting: 'space'`) — derives the NPRE from
the request URL and injects `kibana_space_${spaceId}_default`.

Using `SpaceNPRERouting` requires passing a `ScopeableUrlRequest`
(`KibanaRequest | UrlRequest`) as the first argument. This is enforced
by an overload on `asScoped`, so passing an incompatible `FakeRequest`
(without a `url`) is a compile-time error.

### New request types

- `UrlRequest` — a minimal `FakeRequest` with a `url: URL` property, for
use in non-HTTP contexts (e.g. background tasks) where no real
`KibanaRequest` is available but space-level routing is needed.
- `ScopeableUrlRequest` — `KibanaRequest | UrlRequest`, the type
accepted by the `SpaceNPRERouting` overload of `asScoped`. Route
handlers can pass their incoming `KibanaRequest` directly.

### `OnRequestHandlerFactory`

A mandatory factory (`OnRequestHandlerFactory`) is passed to the
`ClusterClient` constructor instead of a bare `OnRequestHandler`. The
factory is called lazily per scope, producing the right handler based on
the routing mode. The `projectRouting` field is a discriminated union: a
`ScopeableUrlRequest` signals space routing, while `'origin-only'` and
`'all'` cover the remaining modes.

### `getSpaceNPRE` in `@kbn/cps-server-utils`

Broadened to accept `string | { url: URL }` instead of `string |
KibanaRequest`, removing the coupling to `KibanaRequest`. Both
`KibanaRequest` and `UrlRequest` satisfy this structural type. A
defensive runtime guard is retained (with a documented `@throws`) to
protect against JavaScript callers bypassing the type system.

### Known limitation

`getSpaceNPRE` assumes the server base path is `/`. If Kibana were
deployed with a custom `server.basePath` the space segment would not be
stripped before matching, and the function would always resolve to the
default space. CPS is a Serverless-only feature and Serverless
deployments always run at the root path, so this is not a concern in
practice today. This assumption is documented in the JSDoc.

### CLI setup client

`cli_setup`'s `ClusterClient` is on-prem only; it uses
`getRequestHandlerFactory(false)` (CPS disabled), which strips any
`project_routing` header rather than no-oping.

Made with [Cursor](https://cursor.com)

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Rudolf Meijering <skaapgif@gmail.com>
gsoldevila added a commit that referenced this pull request Feb 27, 2026
#254531)

Follows up on #254186.

## Summary

Replaces the four wiring-focused tests in `ClusterClient` that asserted
on internal mock call arguments (factory invocation, `createTransport`
arguments) with behavioral tests that directly verify the
`project_routing` value injected or stripped on synthetic ES request
params.

**New test structure:**

- `#asInternalUser > CPS routing` — verifies that `configureClient`
receives an origin-only `onRequest` handler and that invoking it sets
`project_routing: '_alias:_origin'`.
- `#asScoped().asCurrentUser > CPS routing` — three routing-mode tests
(`'space'`, `'origin-only'`, `'all'`) using
`captureTransportOnRequest()` to exercise the real factory end-to-end
via `createTransport`.
- `#asScoped().asSecondaryAuthUser > CPS routing` — verifies that
`asSecondaryAuthUser` always delegates to `asInternalUser.child()`
without a `Transport` override, inheriting origin-only routing
regardless of the `projectRouting` option.
- `without CPS (project_routing stripping)` — four tests asserting that
`nonCpsRequestHandlerFactory` strips pre-existing `project_routing` from
both `asInternalUser` and `asScoped` requests and never injects it.

Also fixes the mock setup: replaces `mockReturnValue(wrappedFactory)`
with `mockImplementation(getRequestHandlerFactory(...))` so the spy
delegates to the real factory and produces correctly-typed
`OnRequestHandler` instances per routing mode.

Adds a `TODO` in `ClusterClient.asScoped` documenting the known
limitation that callers overriding `Transport` via `child()` bypass the
`onRequest` routing handler.

Made with [Cursor](https://cursor.com)

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
qn895 pushed a commit to qn895/kibana that referenced this pull request Mar 11, 2026
elastic#254531)

Follows up on elastic#254186.

## Summary

Replaces the four wiring-focused tests in `ClusterClient` that asserted
on internal mock call arguments (factory invocation, `createTransport`
arguments) with behavioral tests that directly verify the
`project_routing` value injected or stripped on synthetic ES request
params.

**New test structure:**

- `#asInternalUser > CPS routing` — verifies that `configureClient`
receives an origin-only `onRequest` handler and that invoking it sets
`project_routing: '_alias:_origin'`.
- `#asScoped().asCurrentUser > CPS routing` — three routing-mode tests
(`'space'`, `'origin-only'`, `'all'`) using
`captureTransportOnRequest()` to exercise the real factory end-to-end
via `createTransport`.
- `#asScoped().asSecondaryAuthUser > CPS routing` — verifies that
`asSecondaryAuthUser` always delegates to `asInternalUser.child()`
without a `Transport` override, inheriting origin-only routing
regardless of the `projectRouting` option.
- `without CPS (project_routing stripping)` — four tests asserting that
`nonCpsRequestHandlerFactory` strips pre-existing `project_routing` from
both `asInternalUser` and `asScoped` requests and never injects it.

Also fixes the mock setup: replaces `mockReturnValue(wrappedFactory)`
with `mockImplementation(getRequestHandlerFactory(...))` so the spy
delegates to the real factory and produces correctly-typed
`OnRequestHandler` instances per routing mode.

Adds a `TODO` in `ClusterClient.asScoped` documenting the known
limitation that callers overriding `Transport` via `child()` bypass the
`onRequest` routing handler.

Made with [Cursor](https://cursor.com)

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport:skip This PR does not require backporting release_note:skip Skip the PR/issue when compiling release notes Team:Core Platform Core services: plugins, logging, config, saved objects, http, ES client, i18n, etc t// v9.4.0

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants