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);