From bbfbc755aa7dc3305ad816decf082547e968cf53 Mon Sep 17 00:00:00 2001 From: MT Lewis Date: Mon, 17 Jan 2022 18:26:25 +0000 Subject: [PATCH] [wip] search-backend: result-by-result authorization To do: - add tests for AuthorizedSearchEngine - update router tests - add diff to changeset Signed-off-by: MT Lewis --- .changeset/pink-spoons-hope.md | 5 + packages/backend/src/plugins/search.ts | 4 + .../packages/backend/src/plugins/search.ts | 4 + plugins/search-backend/api-report.md | 6 + plugins/search-backend/package.json | 5 + .../src/service/AuthorizedSearchEngine.ts | 171 ++++++++++++++++++ .../search-backend/src/service/router.test.ts | 18 ++ plugins/search-backend/src/service/router.ts | 26 ++- .../src/service/standaloneServer.ts | 20 +- 9 files changed, 254 insertions(+), 5 deletions(-) create mode 100644 .changeset/pink-spoons-hope.md create mode 100644 plugins/search-backend/src/service/AuthorizedSearchEngine.ts diff --git a/.changeset/pink-spoons-hope.md b/.changeset/pink-spoons-hope.md new file mode 100644 index 0000000000..cc8fba42de --- /dev/null +++ b/.changeset/pink-spoons-hope.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-search-backend': patch +--- + +Add support for filtering search results according to visibility. diff --git a/packages/backend/src/plugins/search.ts b/packages/backend/src/plugins/search.ts index 9a8db0f0f9..4b5363e73d 100644 --- a/packages/backend/src/plugins/search.ts +++ b/packages/backend/src/plugins/search.ts @@ -56,6 +56,7 @@ async function createSearchEngine({ export default async function createPlugin({ logger, + permissions, discovery, config, database, @@ -95,6 +96,9 @@ export default async function createPlugin({ return await createRouter({ engine: indexBuilder.getSearchEngine(), + types: indexBuilder.getDocumentTypes(), + permissions, + config, logger, }); } diff --git a/packages/create-app/templates/default-app/packages/backend/src/plugins/search.ts b/packages/create-app/templates/default-app/packages/backend/src/plugins/search.ts index f23b0c7bcf..a0a1cc3701 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/plugins/search.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/plugins/search.ts @@ -10,6 +10,7 @@ import { DefaultTechDocsCollator } from '@backstage/plugin-techdocs-backend'; export default async function createPlugin({ logger, + permissions, discovery, config, tokenManager, @@ -49,6 +50,9 @@ export default async function createPlugin({ return await createRouter({ engine: indexBuilder.getSearchEngine(), + types: indexBuilder.getDocumentTypes(), + permissions, + config, logger, }); } diff --git a/plugins/search-backend/api-report.md b/plugins/search-backend/api-report.md index cac97bd725..125bd6cc1d 100644 --- a/plugins/search-backend/api-report.md +++ b/plugins/search-backend/api-report.md @@ -3,8 +3,11 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { Config } from '@backstage/config'; +import { DocumentTypeInfo } from '@backstage/plugin-search-backend-node'; import express from 'express'; import { Logger as Logger_2 } from 'winston'; +import { PermissionAuthorizer } from '@backstage/plugin-permission-common'; import { SearchEngine } from '@backstage/plugin-search-backend-node'; // Warning: (ae-missing-release-tag) "createRouter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -17,6 +20,9 @@ export function createRouter(options: RouterOptions): Promise; // @public (undocumented) export type RouterOptions = { engine: SearchEngine; + types: Record; + permissions: PermissionAuthorizer; + config: Config; logger: Logger_2; }; ``` diff --git a/plugins/search-backend/package.json b/plugins/search-backend/package.json index 19b42e62af..4ff4e98e52 100644 --- a/plugins/search-backend/package.json +++ b/plugins/search-backend/package.json @@ -24,11 +24,16 @@ "@backstage/config": "^0.1.13", "@backstage/errors": "^0.2.0", "@backstage/search-common": "^0.2.0", + "@backstage/plugin-auth-backend": "^0.7.0-next.0", + "@backstage/plugin-permission-common": "^0.4.0-next.0", + "@backstage/plugin-permission-node": "^0.4.0-next.0", "@backstage/plugin-search-backend-node": "^0.4.4", "@backstage/types": "^0.1.1", "@types/express": "^4.17.6", + "dataloader": "^2.0.0", "express": "^4.17.1", "express-promise-router": "^4.1.0", + "lodash": "^4.17.21", "winston": "^3.2.1", "yn": "^4.0.0", "zod": "^3.11.6" diff --git a/plugins/search-backend/src/service/AuthorizedSearchEngine.ts b/plugins/search-backend/src/service/AuthorizedSearchEngine.ts new file mode 100644 index 0000000000..6f33307f6f --- /dev/null +++ b/plugins/search-backend/src/service/AuthorizedSearchEngine.ts @@ -0,0 +1,171 @@ +/* + * Copyright 2022 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 { compact, zipObject } from 'lodash'; +import DataLoader from 'dataloader'; +import { + AuthorizeDecision, + AuthorizeQuery, + AuthorizeResult, + PermissionAuthorizer, +} from '@backstage/plugin-permission-common'; +import { + IndexableDocument, + QueryRequestOptions, + QueryTranslator, + SearchEngine, + SearchQuery, + SearchResult, + SearchResultSet, +} from '@backstage/search-common'; +import { DocumentTypeInfo } from '@backstage/plugin-search-backend-node'; +import { Config } from '@backstage/config'; + +export function decodePageCursor(pageCursor?: string): { page: number } { + if (!pageCursor) { + return { page: 0 }; + } + + return { + page: Number(Buffer.from(pageCursor, 'base64').toString('utf-8')), + }; +} + +export function encodePageCursor({ page }: { page: number }): string { + return Buffer.from(`${page}`, 'utf-8').toString('base64'); +} + +export class AuthorizedSearchEngine implements SearchEngine { + private readonly pageSize: number = 25; + private readonly queryLatencyBudgetMs: number; + + constructor( + private readonly searchEngine: SearchEngine, + private readonly types: Record, + private readonly permissions: PermissionAuthorizer, + config: Config, + ) { + this.queryLatencyBudgetMs = + config.getOptionalNumber('search.permissions.queryLatencyBudgetMs') ?? + 1000; + } + + setTranslator(translator: QueryTranslator): void { + this.searchEngine.setTranslator(translator); + } + + async index(type: string, documents: IndexableDocument[]): Promise { + this.searchEngine.index(type, documents); + } + + async query( + query: SearchQuery, + options: QueryRequestOptions, + ): Promise { + const queryStartTime = Date.now(); + + const authorizer = new DataLoader((requests: readonly AuthorizeQuery[]) => + this.permissions.authorize(requests.slice(), options), + ); + + const requestedTypes = query.types || Object.keys(this.types); + const typeDecisions = zipObject( + requestedTypes, + await Promise.all( + requestedTypes.map(type => { + const permission = this.types[type].visibilityPermission; + + return permission + ? authorizer.load({ permission }) + : { result: AuthorizeResult.ALLOW as const }; + }), + ), + ); + + const authorizedTypes = requestedTypes.filter( + type => typeDecisions[type]?.result !== AuthorizeResult.DENY, + ); + + const { page } = decodePageCursor(query.pageCursor); + const targetResults = (page + 1) * this.pageSize; + + let filteredResults: SearchResult[] = []; + let nextPageCursor: string | undefined; + let latencyBudgetExhausted: boolean; + + do { + const nextPage = await this.searchEngine.query( + { ...query, types: authorizedTypes, pageCursor: nextPageCursor }, + options, + ); + + filteredResults = filteredResults.concat( + await this.filterResults(nextPage.results, typeDecisions, authorizer), + ); + + nextPageCursor = nextPage.nextPageCursor; + latencyBudgetExhausted = + Date.now() - queryStartTime > this.queryLatencyBudgetMs; + } while ( + nextPageCursor && + filteredResults.length < targetResults && + !latencyBudgetExhausted + ); + + return { + results: filteredResults.slice( + page * this.pageSize, + (page + 1) * this.pageSize, + ), + previousPageCursor: + page === 0 ? undefined : encodePageCursor({ page: page - 1 }), + nextPageCursor: + !latencyBudgetExhausted && + (nextPageCursor || filteredResults.length > targetResults) + ? encodePageCursor({ page: page + 1 }) + : undefined, + }; + } + + private async filterResults( + results: SearchResult[], + typeDecisions: Record, + authorizer: DataLoader, + ) { + return compact( + await Promise.all( + results.map(result => { + if (typeDecisions[result.type]?.result === AuthorizeResult.ALLOW) { + return result; + } + + const permission = this.types[result.type].visibilityPermission; + const resourceRef = result.document.authorization?.resourceRef; + + if (!permission || !resourceRef) { + return result; + } + + return authorizer + .load({ permission, resourceRef }) + .then(decision => + decision.result === AuthorizeResult.ALLOW ? result : undefined, + ); + }), + ), + ); + } +} diff --git a/plugins/search-backend/src/service/router.test.ts b/plugins/search-backend/src/service/router.test.ts index 407e118a8c..0a94250aeb 100644 --- a/plugins/search-backend/src/service/router.test.ts +++ b/plugins/search-backend/src/service/router.test.ts @@ -15,6 +15,8 @@ */ import { getVoidLogger } from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; +import { PermissionAuthorizer } from '@backstage/plugin-permission-common'; import { IndexBuilder, LunrSearchEngine, @@ -25,6 +27,12 @@ import request from 'supertest'; import { createRouter } from './router'; +const mockPermissionAuthorizer: PermissionAuthorizer = { + authorize: () => { + throw new Error('Not implemented'); + }, +}; + describe('createRouter', () => { let app: express.Express; let mockSearchEngine: jest.Mocked; @@ -36,6 +44,12 @@ describe('createRouter', () => { const router = await createRouter({ engine: indexBuilder.getSearchEngine(), + types: { + 'first-type': {}, + 'second-type': {}, + }, + config: new ConfigReader({ permissions: { enabled: false } }), + permissions: mockPermissionAuthorizer, logger, }); app = express().use(router); @@ -74,6 +88,7 @@ describe('createRouter', () => { 'term[0]=foo', 'term[prop]=value', 'types=foo', + 'types[0]=unknown-type', 'types[length]=10000&types[0]=first-type', 'filters=stringValue', 'pageCursor[0]=1', @@ -101,6 +116,9 @@ describe('createRouter', () => { const router = await createRouter({ engine: indexBuilder.getSearchEngine(), + types: indexBuilder.getDocumentTypes(), + config: new ConfigReader({ permissions: { enabled: false } }), + permissions: mockPermissionAuthorizer, logger, }); app = express().use(router); diff --git a/plugins/search-backend/src/service/router.ts b/plugins/search-backend/src/service/router.ts index 34289d6343..f8cf2ae95b 100644 --- a/plugins/search-backend/src/service/router.ts +++ b/plugins/search-backend/src/service/router.ts @@ -20,9 +20,16 @@ import { Logger } from 'winston'; import { z } from 'zod'; import { errorHandler } from '@backstage/backend-common'; import { InputError } from '@backstage/errors'; +import { Config } from '@backstage/config'; import { JsonObject, JsonValue } from '@backstage/types'; +import { IdentityClient } from '@backstage/plugin-auth-backend'; +import { PermissionAuthorizer } from '@backstage/plugin-permission-common'; import { SearchResultSet } from '@backstage/search-common'; -import { SearchEngine } from '@backstage/plugin-search-backend-node'; +import { + DocumentTypeInfo, + SearchEngine, +} from '@backstage/plugin-search-backend-node'; +import { AuthorizedSearchEngine } from './AuthorizedSearchEngine'; const jsonObjectSchema: z.ZodSchema = z.lazy(() => { const jsonValueSchema: z.ZodSchema = z.lazy(() => @@ -41,6 +48,9 @@ const jsonObjectSchema: z.ZodSchema = z.lazy(() => { export type RouterOptions = { engine: SearchEngine; + types: Record; + permissions: PermissionAuthorizer; + config: Config; logger: Logger; }; @@ -49,15 +59,21 @@ const allowedLocationProtocols = ['http:', 'https:']; export async function createRouter( options: RouterOptions, ): Promise { - const { engine, logger } = options; + const { engine: inputEngine, types, permissions, config, logger } = options; const requestSchema = z.object({ term: z.string().default(''), filters: jsonObjectSchema.optional(), - types: z.array(z.string()).optional(), + types: z + .array(z.string().refine(type => Object.keys(types).includes(type))) + .optional(), pageCursor: z.string().optional(), }); + const engine = config.getOptionalBoolean('permission.enabled') + ? new AuthorizedSearchEngine(inputEngine, types, permissions, config) + : inputEngine; + const filterResultSet = ({ results, ...resultSet }: SearchResultSet) => ({ ...resultSet, results: results.filter(result => { @@ -93,8 +109,10 @@ export async function createRouter( }, pageCursor=${query.pageCursor ?? ''}`, ); + const token = IdentityClient.getBearerToken(req.header('authorization')); + try { - const resultSet = await engine?.query(query); + const resultSet = await engine?.query(query, { token }); res.send(filterResultSet(resultSet)); } catch (err) { diff --git a/plugins/search-backend/src/service/standaloneServer.ts b/plugins/search-backend/src/service/standaloneServer.ts index 9ba9dbd5f7..3f1fdcb34e 100644 --- a/plugins/search-backend/src/service/standaloneServer.ts +++ b/plugins/search-backend/src/service/standaloneServer.ts @@ -14,7 +14,12 @@ * limitations under the License. */ -import { createServiceBuilder } from '@backstage/backend-common'; +import { + createServiceBuilder, + loadBackendConfig, + ServerTokenManager, + SingleHostDiscovery, +} from '@backstage/backend-common'; import { Server } from 'http'; import { Logger } from 'winston'; import { createRouter } from './router'; @@ -22,6 +27,7 @@ import { LunrSearchEngine, IndexBuilder, } from '@backstage/plugin-search-backend-node'; +import { ServerPermissionClient } from '@backstage/plugin-permission-node'; export interface ServerOptions { port: number; @@ -33,14 +39,26 @@ export async function startStandaloneServer( options: ServerOptions, ): Promise { const logger = options.logger.child({ service: 'search-backend' }); + const config = await loadBackendConfig({ logger, argv: process.argv }); const searchEngine = new LunrSearchEngine({ logger }); const indexBuilder = new IndexBuilder({ logger, searchEngine }); + const discovery = SingleHostDiscovery.fromConfig(config); + const tokenManager = ServerTokenManager.fromConfig(config, { + logger, + }); + const permissions = ServerPermissionClient.fromConfig(config, { + discovery, + tokenManager, + }); logger.debug('Starting application server...'); // TODO: stub out some documents/indices? const router = await createRouter({ engine: indexBuilder.getSearchEngine(), + types: indexBuilder.getDocumentTypes(), + permissions, + config, logger, });