[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:
MT Lewis
2022-01-17 18:26:25 +00:00
parent 7da1b7c42a
commit bbfbc755aa
9 changed files with 254 additions and 5 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-search-backend': patch
---
Add support for filtering search results according to visibility.
+4
View File
@@ -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,
});
}
+6
View File
@@ -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;
};
```
+5
View File
@@ -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);
+22 -4
View File
@@ -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,
});