Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 @@ -7,7 +7,8 @@

import { DEFAULT_APP_CATEGORIES } from '@kbn/core/server';
import { actionsMock } from '@kbn/actions-plugin/server/mocks';
import { coreMock, httpServerMock } from '@kbn/core/server/mocks';
import { SavedObjectsErrorHelpers } from '@kbn/core/server';
import { coreMock, httpServerMock, savedObjectsClientMock } from '@kbn/core/server/mocks';
import { featuresPluginMock } from '@kbn/features-plugin/server/mocks';
import { inferenceMock } from '@kbn/inference-plugin/server/mocks';
import { SearchInferenceEndpointsPlugin } from './plugin';
Expand Down Expand Up @@ -111,15 +112,95 @@ describe('SearchInferenceEndpointsPlugin', () => {
});
});

it('endpoints.getForFeature reads inference settings with getScopedClient', async () => {
it('endpoints.getForFeature reads inference settings with the internal client', async () => {
const request = httpServerMock.createKibanaRequest();
await startContract.endpoints.getForFeature('any_feature', request);

expect(coreStart.savedObjects.getScopedClient).toHaveBeenCalledWith(request, {
expect(coreStart.savedObjects.getUnsafeInternalClient).toHaveBeenCalledWith({
includedHiddenTypes: [INFERENCE_SETTINGS_SO_TYPE],
});
});

it('scopes the internal settings client to the namespace of the request', async () => {
const request = httpServerMock.createKibanaRequest();
const scopedClient = savedObjectsClientMock.create();
scopedClient.getCurrentNamespace.mockReturnValue('applications');
coreStart.savedObjects.getScopedClient.mockReturnValue(scopedClient);

const internalClient = savedObjectsClientMock.create();
const spaceScopedClient = savedObjectsClientMock.create();
internalClient.asScopedToNamespace.mockReturnValue(spaceScopedClient);
coreStart.savedObjects.getUnsafeInternalClient.mockReturnValue(internalClient);

await startContract.endpoints.getForFeature('any_feature', request);

expect(internalClient.asScopedToNamespace).toHaveBeenCalledWith('applications');
expect(spaceScopedClient.get).toHaveBeenCalledWith(INFERENCE_SETTINGS_SO_TYPE, 'default');
expect(internalClient.get).not.toHaveBeenCalled();
});

it('does not scope the internal settings client when the request is in the default space', async () => {
const request = httpServerMock.createKibanaRequest();
const internalClient = savedObjectsClientMock.create();
coreStart.savedObjects.getUnsafeInternalClient.mockReturnValue(internalClient);

await startContract.endpoints.getForFeature('any_feature', request);

expect(internalClient.asScopedToNamespace).not.toHaveBeenCalled();
expect(internalClient.get).toHaveBeenCalledWith(INFERENCE_SETTINGS_SO_TYPE, 'default');
});

it('applies the admin-configured model list to users who cannot read the settings saved object', async () => {
const request = httpServerMock.createKibanaRequest();
const scopedClient = savedObjectsClientMock.create();
scopedClient.get.mockRejectedValue(
SavedObjectsErrorHelpers.decorateForbiddenError(
new Error(`Unable to get ${INFERENCE_SETTINGS_SO_TYPE}`)
)
);
coreStart.savedObjects.getScopedClient.mockReturnValue(scopedClient);

const internalClient = savedObjectsClientMock.create();
internalClient.get.mockResolvedValue({
id: 'default',
type: INFERENCE_SETTINGS_SO_TYPE,
references: [],
attributes: { features: [{ feature_id: 'any_feature', endpoints: [{ id: 'allowed' }] }] },
});
coreStart.savedObjects.getUnsafeInternalClient.mockReturnValue(internalClient);

const createConnector = (connectorId: string) => ({
connectorId,
name: connectorId,
type: '.gen-ai',
config: {},
capabilities: {},
isPreconfigured: false,
isInferenceEndpoint: false,
});
const inference = inferenceMock.createStartContract();
inference.getConnectorList.mockResolvedValue([
createConnector('allowed'),
createConnector('hidden'),
] as any);
inference.getConnectorById.mockImplementation(
async (id: string) => createConnector(id) as any
);

const contract = plugin.start(coreStart, { actions: actionsMock.createStart(), inference });
contract.features.register({
featureId: 'any_feature',
featureName: 'Any feature',
featureDescription: 'Any feature',
taskType: 'chat_completion',
recommendedEndpoints: [],
});
const result = await contract.endpoints.getForFeature('any_feature', request);

expect(result.soEntryFound).toBe(true);
expect(result.endpoints.map((e) => e.connectorId)).toEqual(['allowed']);
});

it('creates a separate scoped SO client per request, ensuring space isolation', async () => {
const requestA = httpServerMock.createKibanaRequest();
const requestB = httpServerMock.createKibanaRequest();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import type {
Logger,
Plugin,
PluginInitializerContext,
SavedObjectsClientContract,
SavedObjectsServiceStart,
} from '@kbn/core/server';
import { DEFAULT_APP_CATEGORIES } from '@kbn/core/server';
import { ApiPrivileges } from '@kbn/core-security-server';
Expand Down Expand Up @@ -39,6 +41,20 @@ import {
PLUGIN_NAME,
} from '../common/constants';

// Model settings are admin policy that must apply to every user, including those
// without read access to the settings saved object, so the read bypasses user authz
// while staying scoped to the request's active space.
const getInferenceSettingsClient = (
savedObjects: SavedObjectsServiceStart,
request: KibanaRequest
): SavedObjectsClientContract => {
const internalClient = savedObjects.getUnsafeInternalClient({
includedHiddenTypes: [INFERENCE_SETTINGS_SO_TYPE],
});
const namespace = savedObjects.getScopedClient(request).getCurrentNamespace();
return namespace ? internalClient.asScopedToNamespace(namespace) : internalClient;
};

export class SearchInferenceEndpointsPlugin
implements
Plugin<
Expand Down Expand Up @@ -75,9 +91,7 @@ export class SearchInferenceEndpointsPlugin

const getForFeature = async (featureId: string, request: KibanaRequest) => {
const [coreStart, pluginsStart] = await core.getStartServices();
const soClient = coreStart.savedObjects.getScopedClient(request, {
includedHiddenTypes: [INFERENCE_SETTINGS_SO_TYPE],
});
const soClient = getInferenceSettingsClient(coreStart.savedObjects, request);
const getConnectorById = (id: string) => pluginsStart.inference.getConnectorById(id, request);
return getForFeatureFn(featureRegistry, soClient, getConnectorById, featureId, this.logger);
};
Expand Down Expand Up @@ -178,10 +192,12 @@ export class SearchInferenceEndpointsPlugin
register: featureRegistry.register.bind(featureRegistry),
},
endpoints: {
getForFeature: async (featureId: string, request: KibanaRequest) => {
const soClient = core.savedObjects.getScopedClient(request, {
includedHiddenTypes: [INFERENCE_SETTINGS_SO_TYPE],
});
getForFeature: async (
featureId: string,
request: KibanaRequest
) => {
const soClient = getInferenceSettingsClient(core.savedObjects, request);
const getConnectorById = (id: string) => plugins.inference.getConnectorById(id, request);

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.

This backport introduces a duplicate const getConnectorById declaration. A few lines below (before resolveFeatureEndpoints) the same const getConnectorById = (id: string) => plugins.inference.getConnectorById(id, request); already exists in this block scope. Two const declarations of the same name in the same block is a redeclaration error (TS2451 "Cannot redeclare block-scoped variable 'getConnectorById'"), which breaks the build. The backport didn't apply cleanly here — the line should be removed since the existing declaration below covers it.

Suggested change
const getConnectorById = (id: string) => plugins.inference.getConnectorById(id, request);
const soClient = getInferenceSettingsClient(core.savedObjects, request);

const uiSettingsClient = core.uiSettings.asScopedToClient(
core.savedObjects.getScopedClient(request)
);
Expand Down
Loading