feat(search): add actions to query search engine
the authorized search engine initialization had to be moved to plugin from router as it should also be used in the query action if permissions are enabled. Signed-off-by: Hellgren Heikki <heikki.hellgren@op.fi>
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