Merge pull request #31010 from drodil/search_action

feat(search): add actions to query search engine
This commit is contained in:
Andre Wanlin
2026-05-05 06:04:06 -05:00
committed by GitHub
9 changed files with 600 additions and 72 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-search-backend': patch
---
Added action for search backend to query search engine using the actions registry
+1
View File
@@ -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:
+4
View File
@@ -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.
@@ -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,<h1>hi</h1>',
},
},
],
});
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:'));
});
});
@@ -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<JsonObject> = zv3.lazy(() => {
const jsonValueSchema: zv3.ZodSchema<JsonValue> = 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,
},
};
},
});
};
@@ -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);
});
};
+24 -2
View File
@@ -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,
});
},
});
},
+6 -70
View File
@@ -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<string, DocumentTypeInfo>;
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<express.Router> {
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(
@@ -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 = <T extends SearchResultSet>(
{ 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);