diff --git a/.changeset/fluffy-brooms-sniff.md b/.changeset/fluffy-brooms-sniff.md new file mode 100644 index 0000000000..dc89a26226 --- /dev/null +++ b/.changeset/fluffy-brooms-sniff.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-search-backend': patch +--- + +Added action for search backend to query search engine using the actions registry diff --git a/app-config.yaml b/app-config.yaml index 86391154f3..8c93e85f68 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -191,6 +191,7 @@ backend: pluginSources: - catalog - scaffolder + - search # See README.md in the proxy-backend plugin for information on the configuration format proxy: endpoints: diff --git a/docs/ai/well-known-actions.md b/docs/ai/well-known-actions.md index 547f7d31b7..4c9ea87c5a 100644 --- a/docs/ai/well-known-actions.md +++ b/docs/ai/well-known-actions.md @@ -33,3 +33,7 @@ This is a (non-exhaustive) list of actions that are known to be part of the Acti - `scaffolder.list-scaffolder-tasks` (List Scaffolder Tasks): This allows you to list scaffolder tasks that have been created. - `scaffolder.execute-template` (Execute Scaffolder Template): Executes a Scaffolder template with its template ref and input parameter values. - `scaffolder.get-scaffolder-task-logs` (Get Scaffolder Task Logs): This allows you to fetch the logs of a given scaffolder task. + +### Search + +- `search.query` (Query Search Engine): Query the Backstage search engine for documents across all or selected document types. diff --git a/plugins/search-backend/src/actions/createQueryAction.test.ts b/plugins/search-backend/src/actions/createQueryAction.test.ts new file mode 100644 index 0000000000..54ec4c06a7 --- /dev/null +++ b/plugins/search-backend/src/actions/createQueryAction.test.ts @@ -0,0 +1,315 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { createQueryAction } from './createQueryAction'; +import { actionsRegistryServiceMock } from '@backstage/backend-test-utils/alpha'; + +describe('createQueryAction', () => { + const mockLogger = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + child: jest.fn(), + } as any; + + it('returns results from the search engine', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockGetDocumentTypes = jest.fn().mockReturnValue({ + a: {}, + b: {}, + c: {}, + }); + const mockSearchIndexService = { + getDocumentTypes: mockGetDocumentTypes, + } as any; + const mockQuery = jest.fn().mockResolvedValue({ + results: [ + { + type: 'test', + document: { + title: 'Result 1', + text: 'Text', + location: 'http://example.com/a', + }, + }, + { + type: 'test', + document: { + title: 'Result 2', + text: 'Text', + location: 'https://example.com/b', + }, + }, + ], + nextPageCursor: 'next', + previousPageCursor: 'prev', + numberOfResults: 2, + }); + const mockEngine = { query: mockQuery } as any; + createQueryAction({ + engine: mockEngine, + actionsRegistry: mockActionsRegistry, + searchIndexService: mockSearchIndexService, + logger: mockLogger, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:query', + input: { term: 'foo' }, + }); + + expect(result.output).toEqual({ + results: [ + { + type: 'test', + document: { + title: 'Result 1', + text: 'Text', + location: 'http://example.com/a', + }, + }, + { + type: 'test', + document: { + title: 'Result 2', + text: 'Text', + location: 'https://example.com/b', + }, + }, + ], + nextPageCursor: 'next', + totalItems: 2, + hasMoreResults: true, + }); + }); + + it('sets hasMoreResults to false when there is no nextPageCursor', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockSearchIndexService = { + getDocumentTypes: jest.fn().mockReturnValue({ a: {} }), + } as any; + const mockQuery = jest.fn().mockResolvedValue({ + results: [ + { + type: 'test', + document: { + title: 'Result 1', + text: 'Text', + location: 'http://example.com/a', + }, + }, + ], + numberOfResults: 1, + }); + const mockEngine = { query: mockQuery } as any; + createQueryAction({ + engine: mockEngine, + actionsRegistry: mockActionsRegistry, + searchIndexService: mockSearchIndexService, + logger: mockLogger, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:query', + input: { term: 'foo' }, + }); + + expect(result.output).toMatchObject({ hasMoreResults: false }); + }); + + it('strips the authorization field from documents', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockSearchIndexService = { + getDocumentTypes: jest.fn().mockReturnValue({ a: {} }), + } as any; + const mockQuery = jest.fn().mockResolvedValue({ + results: [ + { + type: 'test', + document: { + title: 'Secret', + text: 'Text', + location: 'http://example.com/secret', + authorization: { resourceRef: 'component:default/secret' }, + }, + }, + ], + }); + const mockEngine = { query: mockQuery } as any; + createQueryAction({ + engine: mockEngine, + actionsRegistry: mockActionsRegistry, + searchIndexService: mockSearchIndexService, + logger: mockLogger, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:query', + input: { term: 'secret' }, + }); + + const output = result.output as any; + expect(output.results).toHaveLength(1); + expect(output.results[0].document).not.toHaveProperty('authorization'); + }); + + it('accepts nested objects in filters', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockSearchIndexService = { + getDocumentTypes: jest.fn().mockReturnValue({ a: {} }), + } as any; + const mockQuery = jest.fn().mockResolvedValue({ results: [] }); + const mockEngine = { query: mockQuery } as any; + createQueryAction({ + engine: mockEngine, + actionsRegistry: mockActionsRegistry, + searchIndexService: mockSearchIndexService, + logger: mockLogger, + }); + + await mockActionsRegistry.invoke({ + id: 'test:query', + input: { + term: 'foo', + filters: { kind: 'Component', metadata: { namespace: 'default' } }, + } as any, + }); + + expect(mockQuery).toHaveBeenCalledWith( + expect.objectContaining({ + filters: { kind: 'Component', metadata: { namespace: 'default' } }, + }), + expect.anything(), + ); + }); + + it('registers successfully when no document types are registered', () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockSearchIndexService = { + getDocumentTypes: jest.fn().mockReturnValue({}), + } as any; + const mockEngine = { query: jest.fn() } as any; + + expect(() => + createQueryAction({ + engine: mockEngine, + actionsRegistry: mockActionsRegistry, + searchIndexService: mockSearchIndexService, + logger: mockLogger, + }), + ).not.toThrow(); + }); + + it('forwards input to the search engine', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockGetDocumentTypes = jest.fn().mockReturnValue({ + a: {}, + b: {}, + c: {}, + }); + const mockSearchIndexService = { + getDocumentTypes: mockGetDocumentTypes, + } as any; + const mockQuery = jest.fn().mockResolvedValue({ results: [] }); + const mockEngine = { query: mockQuery } as any; + createQueryAction({ + engine: mockEngine, + actionsRegistry: mockActionsRegistry, + searchIndexService: mockSearchIndexService, + logger: mockLogger, + }); + + await mockActionsRegistry.invoke({ + id: 'test:query', + input: { + term: 'foo', + types: ['a'], + filters: { x: '1234' }, + pageLimit: 5, + pageCursor: 'abc', + } as any, + }); + expect(mockQuery).toHaveBeenCalledWith( + { + term: 'foo', + types: ['a'], + filters: { x: '1234' }, + pageLimit: 5, + pageCursor: 'abc', + }, + expect.anything(), + ); + }); + + it('filters out results with unsafe location protocols', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockSearchIndexService = { + getDocumentTypes: jest.fn().mockReturnValue({ a: {} }), + } as any; + const infoSpy = jest.fn(); + const logger = { ...mockLogger, info: infoSpy } as any; + const mockQuery = jest.fn().mockResolvedValue({ + results: [ + { + type: 'test', + document: { + title: 'Safe', + text: 'Text', + location: 'https://example.com/safe', + }, + }, + { + type: 'test', + document: { + title: 'XSS', + text: 'Text', + location: 'javascript' + ':alert(1)', + }, + }, + { + type: 'test', + document: { + title: 'Data URI', + text: 'Text', + location: 'data' + ':text/html,

hi

', + }, + }, + ], + }); + const mockEngine = { query: mockQuery } as any; + createQueryAction({ + engine: mockEngine, + actionsRegistry: mockActionsRegistry, + searchIndexService: mockSearchIndexService, + logger, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:query', + input: { term: 'foo' }, + }); + + const output = result.output as any; + expect(output.results).toHaveLength(1); + expect(output.results[0].document.title).toBe('Safe'); + expect(infoSpy).toHaveBeenCalledTimes(2); + expect(infoSpy).toHaveBeenCalledWith( + // eslint-disable-next-line no-script-url + expect.stringContaining('javascript:'), + ); + expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining('data:')); + }); +}); diff --git a/plugins/search-backend/src/actions/createQueryAction.ts b/plugins/search-backend/src/actions/createQueryAction.ts new file mode 100644 index 0000000000..4dc7b17b59 --- /dev/null +++ b/plugins/search-backend/src/actions/createQueryAction.ts @@ -0,0 +1,149 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { z as zv3 } from 'zod/v3'; +import { JsonObject, JsonValue } from '@backstage/types'; +import { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha'; +import { LoggerService } from '@backstage/backend-plugin-api'; +import { SearchEngine } from '@backstage/plugin-search-backend-node'; +import { SearchIndexService } from '@backstage/plugin-search-backend-node/alpha'; +import { filterResultSet, toSearchResults } from '../utils/search_result_utils'; + +const jsonObjectSchema: zv3.ZodSchema = zv3.lazy(() => { + const jsonValueSchema: zv3.ZodSchema = zv3.lazy(() => + zv3.union([ + zv3.string(), + zv3.number(), + zv3.boolean(), + zv3.null(), + zv3.array(jsonValueSchema), + jsonObjectSchema, + ]), + ); + return zv3.record(jsonValueSchema); +}); + +export const createQueryAction = ({ + engine, + searchIndexService, + actionsRegistry, + logger, +}: { + engine: SearchEngine; + searchIndexService: SearchIndexService; + actionsRegistry: ActionsRegistryService; + logger: LoggerService; +}) => { + const allTypes = Object.keys(searchIndexService.getDocumentTypes()); + const quotedTypes = allTypes.map(t => JSON.stringify(t)).join(', '); + const typesDescription = + allTypes.length > 0 + ? `The supported document types are: ${quotedTypes}.` + : ''; + actionsRegistry.register({ + name: 'query', + title: 'Query Search Engine', + description: ` +This allows you to query the search engine for documents. +You can search across all document types, or restrict the query to specific types. +${typesDescription} +Pagination is supported via the \`pageLimit\` and \`pageCursor\` parameters and is enabled by default with limit of 10. +Results are returned in a paginated format, along with \`pageCursor\` for navigating to the next page of results. + `, + attributes: { + readOnly: true, + }, + schema: { + input: z => + z.object({ + term: z.string().describe('The search term to query for'), + types: (allTypes.length > 0 + ? z.array(z.enum(allTypes as [string, ...string[]])) + : z.array(z.string()) + ) + .optional() + .describe('The types of documents to query for'), + filters: jsonObjectSchema + .optional() + .describe('The filters to apply to the query'), + pageLimit: z + .number() + .optional() + .describe( + 'The number of results to return per page. Defaults to 10.', + ) + .default(10), + pageCursor: z + .string() + .optional() + .describe('The cursor for the next page of results'), + }), + output: z => + z.object({ + results: z + .array( + z.object({ + type: z.string().describe('Document type'), + document: z + .object({ + title: z.string().describe('Document title'), + text: z.string().describe('Document text content'), + location: z + .string() + .describe('Document location, e.g. URL'), + }) + .passthrough(), + highlight: z + .object({ + preTag: z.string(), + postTag: z.string(), + fields: z.record(z.string(), z.string()), + }) + .optional() + .describe('Optional result highlight that matches the query'), + rank: z.number().optional().describe('The rank of the result'), + }), + ) + .describe('The search results'), + nextPageCursor: z + .string() + .optional() + .describe('The cursor for the next page of results, if any'), + totalItems: z + .number() + .optional() + .describe('The total number of results found'), + hasMoreResults: z + .boolean() + .describe('Whether there are more results'), + }), + }, + action: async ({ input, credentials }) => { + const resp = await engine.query(input, { credentials }); + const { results, nextPageCursor, numberOfResults } = filterResultSet( + toSearchResults(resp), + logger, + ); + return { + output: { + results, + nextPageCursor, + totalItems: numberOfResults, + hasMoreResults: nextPageCursor !== undefined, + }, + }; + }, + }); +}; diff --git a/plugins/search-backend/src/actions/index.ts b/plugins/search-backend/src/actions/index.ts new file mode 100644 index 0000000000..b56ed48b48 --- /dev/null +++ b/plugins/search-backend/src/actions/index.ts @@ -0,0 +1,34 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { SearchEngine } from '@backstage/plugin-search-backend-node'; +import { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha'; +import { createQueryAction } from './createQueryAction.ts'; +import { SearchIndexService } from '@backstage/plugin-search-backend-node/alpha'; +import { LifecycleService, LoggerService } from '@backstage/backend-plugin-api'; + +export const registerActions = (options: { + engine: SearchEngine; + actionsRegistry: ActionsRegistryService; + lifecycle: LifecycleService; + searchIndexService: SearchIndexService; + logger: LoggerService; +}) => { + const { lifecycle } = options; + // Register after startup to ensure all document types are registered + lifecycle.addStartupHook(() => { + createQueryAction(options); + }); +}; diff --git a/plugins/search-backend/src/plugin.ts b/plugins/search-backend/src/plugin.ts index 6f4f0dc4cb..eca81282ba 100644 --- a/plugins/search-backend/src/plugin.ts +++ b/plugins/search-backend/src/plugin.ts @@ -33,6 +33,9 @@ import { } from '@backstage/plugin-search-backend-node/alpha'; import { createRouter } from './service/router'; +import { AuthorizedSearchEngine } from './service/AuthorizedSearchEngine.ts'; +import { registerActions } from './actions'; +import { actionsRegistryServiceRef } from '@backstage/backend-plugin-api/alpha'; class SearchIndexRegistry implements SearchIndexRegistryExtensionPoint { private collators: RegisterCollatorParameters[] = []; @@ -100,6 +103,7 @@ export default createBackendPlugin({ httpAuth: coreServices.httpAuth, lifecycle: coreServices.rootLifecycle, searchIndexService: searchIndexServiceRef, + actionsRegistry: actionsRegistryServiceRef, }, async init({ config, @@ -111,6 +115,7 @@ export default createBackendPlugin({ httpAuth, lifecycle, searchIndexService, + actionsRegistry, }) { let searchEngine = searchEngineRegistry.getSearchEngine(); if (!searchEngine) { @@ -135,6 +140,16 @@ export default createBackendPlugin({ await searchIndexService.stop(); }); + const engine = config.getOptionalBoolean('permission.enabled') + ? new AuthorizedSearchEngine( + searchEngine, + searchIndexService.getDocumentTypes(), + permissions, + auth, + config, + ) + : searchEngine; + const router = await createRouter({ config, discovery, @@ -142,11 +157,18 @@ export default createBackendPlugin({ auth, httpAuth, logger, - engine: searchEngine, + engine, types: searchIndexService.getDocumentTypes(), }); - http.use(router); + + registerActions({ + engine, + actionsRegistry, + lifecycle, + searchIndexService, + logger, + }); }, }); }, diff --git a/plugins/search-backend/src/service/router.ts b/plugins/search-backend/src/service/router.ts index 2be6612c8b..16aff3aa19 100644 --- a/plugins/search-backend/src/service/router.ts +++ b/plugins/search-backend/src/service/router.ts @@ -19,18 +19,10 @@ import { z } from 'zod/v3'; import { InputError } from '@backstage/errors'; import { Config } from '@backstage/config'; import { JsonObject, JsonValue } from '@backstage/types'; -import { - PermissionAuthorizer, - PermissionEvaluator, - toPermissionEvaluator, -} from '@backstage/plugin-permission-common'; -import { - DocumentTypeInfo, - IndexableResultSet, - SearchResultSet, -} from '@backstage/plugin-search-common'; +import { PermissionEvaluator } from '@backstage/plugin-permission-common'; +import { DocumentTypeInfo } from '@backstage/plugin-search-common'; +import { filterResultSet, toSearchResults } from '../utils/search_result_utils'; import { SearchEngine } from '@backstage/plugin-search-backend-node'; -import { AuthorizedSearchEngine } from './AuthorizedSearchEngine'; import { createOpenApiRouter } from '../schema/openapi'; import { AuthService, @@ -61,7 +53,7 @@ export type RouterOptions = { engine: SearchEngine; types: Record; discovery?: DiscoveryService; - permissions: PermissionEvaluator | PermissionAuthorizer; + permissions: PermissionEvaluator; config: Config; logger: LoggerService; auth: AuthService; @@ -70,8 +62,6 @@ export type RouterOptions = { const defaultMaxPageLimit = 100; const defaultMaxTermLength = 100; -const allowedLocationProtocols = ['http:', 'https:']; - /** * @internal */ @@ -79,15 +69,7 @@ export async function createRouter( options: RouterOptions, ): Promise { const router = await createOpenApiRouter(); - const { - engine: inputEngine, - types, - permissions, - config, - logger, - auth, - httpAuth, - } = options; + const { engine, types, config, logger, auth, httpAuth } = options; const maxPageLimit = config.getOptionalNumber('search.maxPageLimit') ?? defaultMaxPageLimit; @@ -121,52 +103,6 @@ export async function createRouter( .optional(), }); - let permissionEvaluator: PermissionEvaluator; - if ('authorizeConditional' in permissions) { - permissionEvaluator = permissions as PermissionEvaluator; - } else { - logger.warn( - 'PermissionAuthorizer is deprecated. Please use an instance of PermissionEvaluator instead of PermissionAuthorizer in PluginEnvironment#permissions', - ); - permissionEvaluator = toPermissionEvaluator(permissions); - } - - const engine = config.getOptionalBoolean('permission.enabled') - ? new AuthorizedSearchEngine( - inputEngine, - types, - permissionEvaluator, - auth, - config, - ) - : inputEngine; - - const filterResultSet = ({ results, ...resultSet }: SearchResultSet) => ({ - ...resultSet, - results: results.filter(result => { - const protocol = new URL(result.document.location, 'https://example.com') - .protocol; - const isAllowed = allowedLocationProtocols.includes(protocol); - if (!isAllowed) { - logger.info( - `Rejected search result for "${result.document.title}" as location protocol "${protocol}" is unsafe`, - ); - } - return isAllowed; - }), - }); - - const toSearchResults = (resultSet: IndexableResultSet): SearchResultSet => ({ - ...resultSet, - results: resultSet.results.map(result => ({ - ...result, - document: { - ...result.document, - authorization: undefined, - }, - })), - }); - router.get('/query', async (req, res) => { const parseResult = requestSchema.passthrough().safeParse(req.query); @@ -195,7 +131,7 @@ export async function createRouter( credentials, }); - res.json(filterResultSet(toSearchResults(resultSet))); + res.json(filterResultSet(toSearchResults(resultSet), logger)); } catch (error) { // Log the error message here, but don't expose it to the user in the response logger.error( diff --git a/plugins/search-backend/src/utils/search_result_utils.ts b/plugins/search-backend/src/utils/search_result_utils.ts new file mode 100644 index 0000000000..acb754c6c4 --- /dev/null +++ b/plugins/search-backend/src/utils/search_result_utils.ts @@ -0,0 +1,62 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + IndexableResultSet, + SearchResultSet, +} from '@backstage/plugin-search-common'; +import { LoggerService } from '@backstage/backend-plugin-api'; + +const allowedLocationProtocols = ['http:', 'https:']; + +/** + * Converts an IndexableResultSet to a SearchResultSet by stripping internal + * fields (e.g. authorization) that must not be exposed to callers. + * @internal + */ +export const toSearchResults = (resultSet: IndexableResultSet) => ({ + ...resultSet, + results: resultSet.results.map(result => ({ + ...result, + document: { + ...result.document, + authorization: undefined, + }, + })), +}); + +/** + * Filters a SearchResultSet to remove results whose document location uses an + * unsafe protocol (anything other than http: or https:). + * @internal + */ +export const filterResultSet = ( + { results, ...resultSet }: T, + logger: LoggerService, +): T => + ({ + ...resultSet, + results: results.filter(result => { + const protocol = new URL(result.document.location, 'https://example.com') + .protocol; + const isAllowed = allowedLocationProtocols.includes(protocol); + if (!isAllowed) { + logger.info( + `Rejected search result for "${result.document.title}" as location protocol "${protocol}" is unsafe`, + ); + } + return isAllowed; + }), + } as T);