Skip to content
Closed
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();

Expand All @@ -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);
Expand All @@ -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({
Expand All @@ -358,15 +359,15 @@ 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);

expect((params.body as Record<string, unknown>).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({
Expand All @@ -379,15 +380,43 @@ 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);

expect((params.body as Record<string, unknown>).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<string, unknown>).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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);
Expand All @@ -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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).project_routing).toBeUndefined();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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);
}
}
};
}
3 changes: 1 addition & 2 deletions src/core/packages/elasticsearch/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,8 @@ export type {
IClusterClient,
ICustomClusterClient,
AsScopedOptions,
OriginOnlyRouting,
SpaceNPRERouting,
AllProjectsRouting,
RequestHeaderRouting,
ScopeableRequest,
UnauthorizedErrorHandlerResult,
UnauthorizedErrorHandler,
Expand Down
Loading
Loading