diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6ff93728051f7..e144d638106f3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23987,6 +23987,9 @@ packages: third-party-web@0.29.2: resolution: {integrity: sha512-fegtha91tq2DHphyoiBXVHjVi2YG9zFaRnboT9C28tO1en9Y3wJsfspuy40F+u5wl3hHVbw7cnd1b67kEGHb8g==} + third-party-web@0.30.0: + resolution: {integrity: sha512-p+PfyL5U0ediGzvwPzMSdD9MnC8rHW6nWJJVLIQvqN7RsSqfaZ+7n4c9GILqH36EYR0eXB9CzAeCRnKFr/9uJg==} + thread-stream@2.7.0: resolution: {integrity: sha512-qQiRWsU/wvNolI6tbbCKd9iKaTnCXsTwVxhhKM6nctPdujTyztjlbUkUTUymidWcMnZ5pWR0ej4a0tjsW021vw==} @@ -30124,7 +30127,7 @@ snapshots: '@paulirish/trace_engine@0.0.65': dependencies: legacy-javascript: 0.0.1 - third-party-web: 0.29.2 + third-party-web: 0.30.0 '@pdf-lib/standard-fonts@1.0.0': dependencies: @@ -43558,6 +43561,8 @@ snapshots: third-party-web@0.29.2: {} + third-party-web@0.30.0: {} + thread-stream@2.7.0: dependencies: real-require: 0.2.0 diff --git a/x-pack/platform/plugins/shared/search_inference_endpoints/server/plugin.test.ts b/x-pack/platform/plugins/shared/search_inference_endpoints/server/plugin.test.ts index a30efb0805f29..6b8bc662ad4c5 100644 --- a/x-pack/platform/plugins/shared/search_inference_endpoints/server/plugin.test.ts +++ b/x-pack/platform/plugins/shared/search_inference_endpoints/server/plugin.test.ts @@ -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'; @@ -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(); diff --git a/x-pack/platform/plugins/shared/search_inference_endpoints/server/plugin.ts b/x-pack/platform/plugins/shared/search_inference_endpoints/server/plugin.ts index 4d143dca92de7..290827977a372 100644 --- a/x-pack/platform/plugins/shared/search_inference_endpoints/server/plugin.ts +++ b/x-pack/platform/plugins/shared/search_inference_endpoints/server/plugin.ts @@ -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'; @@ -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< @@ -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); }; @@ -179,9 +193,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) );