Skip to content
Merged
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
7 changes: 6 additions & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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 @@ -189,9 +203,7 @@ export class SearchInferenceEndpointsPlugin
},
endpoints: {
getForFeature: async (featureId: string, request: KibanaRequest) => {
const soClient = core.savedObjects.getScopedClient(request, {
includedHiddenTypes: [INFERENCE_SETTINGS_SO_TYPE],
});
const soClient = getInferenceSettingsClient(core.savedObjects, request);
const uiSettingsClient = core.uiSettings.asScopedToClient(
core.savedObjects.getScopedClient(request)
);
Expand Down
Loading