Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -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 @@ -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(
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 @@ -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,
Expand All @@ -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,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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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,
});

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

Expand All @@ -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);
Expand All @@ -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<string, unknown>).project_routing).toBe(PROJECT_ROUTING_ORIGIN);
expect((params.body as Record<string, unknown>).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<string, unknown>).project_routing).toBeUndefined();
expect((params.body as Record<string, unknown>).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<string, unknown>).project_routing).toBe(PROJECT_ROUTING_ALL);
expect((params.body as Record<string, unknown>).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<string, unknown>).project_routing).toBe(
getSpaceNPRE('my-space')
);
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,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';

Expand All @@ -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);
}
};
}
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