[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 <mtlewis@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-search-backend': patch
|
||||
---
|
||||
|
||||
Add support for filtering search results according to visibility.
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<express.Router>;
|
||||
// @public (undocumented)
|
||||
export type RouterOptions = {
|
||||
engine: SearchEngine;
|
||||
types: Record<string, DocumentTypeInfo>;
|
||||
permissions: PermissionAuthorizer;
|
||||
config: Config;
|
||||
logger: Logger_2;
|
||||
};
|
||||
```
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<string, DocumentTypeInfo>,
|
||||
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<void> {
|
||||
this.searchEngine.index(type, documents);
|
||||
}
|
||||
|
||||
async query(
|
||||
query: SearchQuery,
|
||||
options: QueryRequestOptions,
|
||||
): Promise<SearchResultSet> {
|
||||
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<string, AuthorizeDecision>,
|
||||
authorizer: DataLoader<AuthorizeQuery, AuthorizeDecision>,
|
||||
) {
|
||||
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,
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<SearchEngine>;
|
||||
@@ -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);
|
||||
|
||||
@@ -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<JsonObject> = z.lazy(() => {
|
||||
const jsonValueSchema: z.ZodSchema<JsonValue> = z.lazy(() =>
|
||||
@@ -41,6 +48,9 @@ const jsonObjectSchema: z.ZodSchema<JsonObject> = z.lazy(() => {
|
||||
|
||||
export type RouterOptions = {
|
||||
engine: SearchEngine;
|
||||
types: Record<string, DocumentTypeInfo>;
|
||||
permissions: PermissionAuthorizer;
|
||||
config: Config;
|
||||
logger: Logger;
|
||||
};
|
||||
|
||||
@@ -49,15 +59,21 @@ const allowedLocationProtocols = ['http:', 'https:'];
|
||||
export async function createRouter(
|
||||
options: RouterOptions,
|
||||
): Promise<express.Router> {
|
||||
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) {
|
||||
|
||||
@@ -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<Server> {
|
||||
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,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user