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 @@ -113,15 +114,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 @@ -40,6 +42,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 @@ -76,9 +92,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 @@ -188,10 +202,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
) => {

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. The identical const getConnectorById = (id: string) => plugins.inference.getConnectorById(id, request); already exists a few lines below (right after uiSettingsClient), so this scope now declares getConnectorById twice, which is a "Cannot redeclare block-scoped variable" compile error. The cherry-pick added this line without removing/reconciling the pre-existing one that was already on the 9.5 base. Remove this newly added line (the later declaration already covers all uses):

Suggested change
) => {

const soClient = getInferenceSettingsClient(core.savedObjects, request);
const getConnectorById = (id: string) => plugins.inference.getConnectorById(id, request);
const uiSettingsClient = core.uiSettings.asScopedToClient(
core.savedObjects.getScopedClient(request)
);
Expand Down
Loading