Merge pull request #31010 from drodil/search_action
feat(search): add actions to query search engine
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-search-backend': patch
|
||||
---
|
||||
|
||||
Added action for search backend to query search engine using the actions registry
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user