@backstage/plugin-search-backend 0.3.1 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -0
- package/dist/index.cjs.js +100 -3
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/package.json +12 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,66 @@
|
|
|
1
1
|
# @backstage/plugin-search-backend
|
|
2
2
|
|
|
3
|
+
## 0.4.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies
|
|
8
|
+
- @backstage/plugin-auth-backend@0.9.0
|
|
9
|
+
- @backstage/backend-common@0.10.6
|
|
10
|
+
- @backstage/plugin-permission-node@0.4.2
|
|
11
|
+
|
|
12
|
+
## 0.4.1-next.1
|
|
13
|
+
|
|
14
|
+
### Patch Changes
|
|
15
|
+
|
|
16
|
+
- Updated dependencies
|
|
17
|
+
- @backstage/plugin-auth-backend@0.9.0-next.1
|
|
18
|
+
- @backstage/backend-common@0.10.6-next.0
|
|
19
|
+
- @backstage/plugin-permission-node@0.4.2-next.1
|
|
20
|
+
|
|
21
|
+
## 0.4.1-next.0
|
|
22
|
+
|
|
23
|
+
### Patch Changes
|
|
24
|
+
|
|
25
|
+
- Updated dependencies
|
|
26
|
+
- @backstage/plugin-auth-backend@0.9.0-next.0
|
|
27
|
+
- @backstage/plugin-permission-node@0.4.2-next.0
|
|
28
|
+
|
|
29
|
+
## 0.4.0
|
|
30
|
+
|
|
31
|
+
### Minor Changes
|
|
32
|
+
|
|
33
|
+
- bbfbc755aa: **BREAKING** Added three additional required properties to `createRouter` to support filtering search results based on permissions. To make this change to an existing app, add the required parameters to the `createRouter` call in `packages/backend/src/plugins/search.ts`:
|
|
34
|
+
|
|
35
|
+
```diff
|
|
36
|
+
export default async function createPlugin({
|
|
37
|
+
logger,
|
|
38
|
+
+ permissions,
|
|
39
|
+
discovery,
|
|
40
|
+
config,
|
|
41
|
+
tokenManager,
|
|
42
|
+
}: PluginEnvironment) {
|
|
43
|
+
/* ... */
|
|
44
|
+
|
|
45
|
+
return await createRouter({
|
|
46
|
+
engine: indexBuilder.getSearchEngine(),
|
|
47
|
+
+ types: indexBuilder.getDocumentTypes(),
|
|
48
|
+
+ permissions,
|
|
49
|
+
+ config,
|
|
50
|
+
logger,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Patch Changes
|
|
56
|
+
|
|
57
|
+
- Updated dependencies
|
|
58
|
+
- @backstage/plugin-search-backend-node@0.4.5
|
|
59
|
+
- @backstage/plugin-auth-backend@0.8.0
|
|
60
|
+
- @backstage/search-common@0.2.2
|
|
61
|
+
- @backstage/backend-common@0.10.5
|
|
62
|
+
- @backstage/plugin-permission-node@0.4.1
|
|
63
|
+
|
|
3
64
|
## 0.3.1
|
|
4
65
|
|
|
5
66
|
### Patch Changes
|
package/dist/index.cjs.js
CHANGED
|
@@ -6,10 +6,105 @@ var Router = require('express-promise-router');
|
|
|
6
6
|
var zod = require('zod');
|
|
7
7
|
var backendCommon = require('@backstage/backend-common');
|
|
8
8
|
var errors = require('@backstage/errors');
|
|
9
|
+
var pluginAuthBackend = require('@backstage/plugin-auth-backend');
|
|
10
|
+
var lodash = require('lodash');
|
|
11
|
+
var qs = require('qs');
|
|
12
|
+
var DataLoader = require('dataloader');
|
|
13
|
+
var pluginPermissionCommon = require('@backstage/plugin-permission-common');
|
|
9
14
|
|
|
10
15
|
function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; }
|
|
11
16
|
|
|
12
17
|
var Router__default = /*#__PURE__*/_interopDefaultLegacy(Router);
|
|
18
|
+
var qs__default = /*#__PURE__*/_interopDefaultLegacy(qs);
|
|
19
|
+
var DataLoader__default = /*#__PURE__*/_interopDefaultLegacy(DataLoader);
|
|
20
|
+
|
|
21
|
+
function decodePageCursor(pageCursor) {
|
|
22
|
+
if (!pageCursor) {
|
|
23
|
+
return { page: 0 };
|
|
24
|
+
}
|
|
25
|
+
const page = Number(Buffer.from(pageCursor, "base64").toString("utf-8"));
|
|
26
|
+
if (isNaN(page)) {
|
|
27
|
+
throw new errors.InputError("Invalid page cursor");
|
|
28
|
+
}
|
|
29
|
+
if (page < 0) {
|
|
30
|
+
throw new errors.InputError("Invalid page cursor");
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
page
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function encodePageCursor({ page }) {
|
|
37
|
+
return Buffer.from(`${page}`, "utf-8").toString("base64");
|
|
38
|
+
}
|
|
39
|
+
class AuthorizedSearchEngine {
|
|
40
|
+
constructor(searchEngine, types, permissions, config) {
|
|
41
|
+
this.searchEngine = searchEngine;
|
|
42
|
+
this.types = types;
|
|
43
|
+
this.permissions = permissions;
|
|
44
|
+
this.pageSize = 25;
|
|
45
|
+
var _a;
|
|
46
|
+
this.queryLatencyBudgetMs = (_a = config.getOptionalNumber("search.permissions.queryLatencyBudgetMs")) != null ? _a : 1e3;
|
|
47
|
+
}
|
|
48
|
+
setTranslator(translator) {
|
|
49
|
+
this.searchEngine.setTranslator(translator);
|
|
50
|
+
}
|
|
51
|
+
async index(type, documents) {
|
|
52
|
+
this.searchEngine.index(type, documents);
|
|
53
|
+
}
|
|
54
|
+
async query(query, options) {
|
|
55
|
+
const queryStartTime = Date.now();
|
|
56
|
+
const authorizer = new DataLoader__default["default"]((requests) => this.permissions.authorize(requests.slice(), options), {
|
|
57
|
+
cacheKeyFn: ({ permission: { name }, resourceRef }) => qs__default["default"].stringify({ name, resourceRef })
|
|
58
|
+
});
|
|
59
|
+
const requestedTypes = query.types || Object.keys(this.types);
|
|
60
|
+
const typeDecisions = lodash.zipObject(requestedTypes, await Promise.all(requestedTypes.map((type) => {
|
|
61
|
+
var _a;
|
|
62
|
+
const permission = (_a = this.types[type]) == null ? void 0 : _a.visibilityPermission;
|
|
63
|
+
return permission ? authorizer.load({ permission }) : { result: pluginPermissionCommon.AuthorizeResult.ALLOW };
|
|
64
|
+
})));
|
|
65
|
+
const authorizedTypes = requestedTypes.filter((type) => {
|
|
66
|
+
var _a;
|
|
67
|
+
return ((_a = typeDecisions[type]) == null ? void 0 : _a.result) !== pluginPermissionCommon.AuthorizeResult.DENY;
|
|
68
|
+
});
|
|
69
|
+
const resultByResultFilteringRequired = authorizedTypes.some((type) => {
|
|
70
|
+
var _a;
|
|
71
|
+
return ((_a = typeDecisions[type]) == null ? void 0 : _a.result) === pluginPermissionCommon.AuthorizeResult.CONDITIONAL;
|
|
72
|
+
});
|
|
73
|
+
if (!resultByResultFilteringRequired) {
|
|
74
|
+
return this.searchEngine.query({ ...query, types: authorizedTypes }, options);
|
|
75
|
+
}
|
|
76
|
+
const { page } = decodePageCursor(query.pageCursor);
|
|
77
|
+
const targetResults = (page + 1) * this.pageSize;
|
|
78
|
+
let filteredResults = [];
|
|
79
|
+
let nextPageCursor;
|
|
80
|
+
let latencyBudgetExhausted = false;
|
|
81
|
+
do {
|
|
82
|
+
const nextPage = await this.searchEngine.query({ ...query, types: authorizedTypes, pageCursor: nextPageCursor }, options);
|
|
83
|
+
filteredResults = filteredResults.concat(await this.filterResults(nextPage.results, typeDecisions, authorizer));
|
|
84
|
+
nextPageCursor = nextPage.nextPageCursor;
|
|
85
|
+
latencyBudgetExhausted = Date.now() - queryStartTime > this.queryLatencyBudgetMs;
|
|
86
|
+
} while (nextPageCursor && filteredResults.length < targetResults && !latencyBudgetExhausted);
|
|
87
|
+
return {
|
|
88
|
+
results: filteredResults.slice(page * this.pageSize, (page + 1) * this.pageSize),
|
|
89
|
+
previousPageCursor: page === 0 ? void 0 : encodePageCursor({ page: page - 1 }),
|
|
90
|
+
nextPageCursor: !latencyBudgetExhausted && (nextPageCursor || filteredResults.length > targetResults) ? encodePageCursor({ page: page + 1 }) : void 0
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
async filterResults(results, typeDecisions, authorizer) {
|
|
94
|
+
return lodash.compact(await Promise.all(results.map((result) => {
|
|
95
|
+
var _a, _b, _c;
|
|
96
|
+
if (((_a = typeDecisions[result.type]) == null ? void 0 : _a.result) === pluginPermissionCommon.AuthorizeResult.ALLOW) {
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
const permission = (_b = this.types[result.type]) == null ? void 0 : _b.visibilityPermission;
|
|
100
|
+
const resourceRef = (_c = result.document.authorization) == null ? void 0 : _c.resourceRef;
|
|
101
|
+
if (!permission || !resourceRef) {
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
return authorizer.load({ permission, resourceRef }).then((decision) => decision.result === pluginPermissionCommon.AuthorizeResult.ALLOW ? result : void 0);
|
|
105
|
+
})));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
13
108
|
|
|
14
109
|
const jsonObjectSchema = zod.z.lazy(() => {
|
|
15
110
|
const jsonValueSchema = zod.z.lazy(() => zod.z.union([
|
|
@@ -24,13 +119,14 @@ const jsonObjectSchema = zod.z.lazy(() => {
|
|
|
24
119
|
});
|
|
25
120
|
const allowedLocationProtocols = ["http:", "https:"];
|
|
26
121
|
async function createRouter(options) {
|
|
27
|
-
const { engine, logger } = options;
|
|
122
|
+
const { engine: inputEngine, types, permissions, config, logger } = options;
|
|
28
123
|
const requestSchema = zod.z.object({
|
|
29
124
|
term: zod.z.string().default(""),
|
|
30
125
|
filters: jsonObjectSchema.optional(),
|
|
31
|
-
types: zod.z.array(zod.z.string()).optional(),
|
|
126
|
+
types: zod.z.array(zod.z.string().refine((type) => Object.keys(types).includes(type))).optional(),
|
|
32
127
|
pageCursor: zod.z.string().optional()
|
|
33
128
|
});
|
|
129
|
+
const engine = config.getOptionalBoolean("permission.enabled") ? new AuthorizedSearchEngine(inputEngine, types, permissions, config) : inputEngine;
|
|
34
130
|
const filterResultSet = ({ results, ...resultSet }) => ({
|
|
35
131
|
...resultSet,
|
|
36
132
|
results: results.filter((result) => {
|
|
@@ -51,8 +147,9 @@ async function createRouter(options) {
|
|
|
51
147
|
}
|
|
52
148
|
const query = parseResult.data;
|
|
53
149
|
logger.info(`Search request received: term="${query.term}", filters=${JSON.stringify(query.filters)}, types=${query.types ? query.types.join(",") : ""}, pageCursor=${(_a = query.pageCursor) != null ? _a : ""}`);
|
|
150
|
+
const token = pluginAuthBackend.IdentityClient.getBearerToken(req.header("authorization"));
|
|
54
151
|
try {
|
|
55
|
-
const resultSet = await (engine == null ? void 0 : engine.query(query));
|
|
152
|
+
const resultSet = await (engine == null ? void 0 : engine.query(query, { token }));
|
|
56
153
|
res.send(filterResultSet(resultSet));
|
|
57
154
|
} catch (err) {
|
|
58
155
|
throw new Error(`There was a problem performing the search query. ${err}`);
|
package/dist/index.cjs.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.cjs.js","sources":["../src/service/router.ts"],"sourcesContent":["/*\n * Copyright 2021 The Backstage Authors\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport express from 'express';\nimport Router from 'express-promise-router';\nimport { Logger } from 'winston';\nimport { z } from 'zod';\nimport { errorHandler } from '@backstage/backend-common';\nimport { InputError } from '@backstage/errors';\nimport { JsonObject, JsonValue } from '@backstage/types';\nimport { SearchResultSet } from '@backstage/search-common';\nimport { SearchEngine } from '@backstage/plugin-search-backend-node';\n\nconst jsonObjectSchema: z.ZodSchema<JsonObject> = z.lazy(() => {\n const jsonValueSchema: z.ZodSchema<JsonValue> = z.lazy(() =>\n z.union([\n z.string(),\n z.number(),\n z.boolean(),\n z.null(),\n z.array(jsonValueSchema),\n jsonObjectSchema,\n ]),\n );\n\n return z.record(jsonValueSchema);\n});\n\nexport type RouterOptions = {\n engine: SearchEngine;\n logger: Logger;\n};\n\nconst allowedLocationProtocols = ['http:', 'https:'];\n\nexport async function createRouter(\n options: RouterOptions,\n): Promise<express.Router> {\n const { engine, logger } = options;\n\n const requestSchema = z.object({\n term: z.string().default(''),\n filters: jsonObjectSchema.optional(),\n types: z.array(z.string()).optional(),\n pageCursor: z.string().optional(),\n });\n\n const filterResultSet = ({ results, ...resultSet }: SearchResultSet) => ({\n ...resultSet,\n results: results.filter(result => {\n const protocol = new URL(result.document.location, 'https://example.com')\n .protocol;\n const isAllowed = allowedLocationProtocols.includes(protocol);\n if (!isAllowed) {\n logger.info(\n `Rejected search result for \"${result.document.title}\" as location protocol \"${protocol}\" is unsafe`,\n );\n }\n return isAllowed;\n }),\n });\n\n const router = Router();\n router.get(\n '/query',\n async (req: express.Request, res: express.Response<SearchResultSet>) => {\n const parseResult = requestSchema.safeParse(req.query);\n\n if (!parseResult.success) {\n throw new InputError(`Invalid query string: ${parseResult.error}`);\n }\n\n const query = parseResult.data;\n\n logger.info(\n `Search request received: term=\"${\n query.term\n }\", filters=${JSON.stringify(query.filters)}, types=${\n query.types ? query.types.join(',') : ''\n }, pageCursor=${query.pageCursor ?? ''}`,\n );\n\n try {\n const resultSet = await engine?.query(query);\n\n res.send(filterResultSet(resultSet));\n } catch (err) {\n throw new Error(\n `There was a problem performing the search query. ${err}`,\n );\n }\n },\n );\n\n router.use(errorHandler());\n\n return router;\n}\n"],"names":["z","Router","InputError","errorHandler"],"mappings":";;;;;;;;;;;;;AA0BA,MAAM,mBAA4CA,MAAE,KAAK,MAAM;AAC7D,QAAM,kBAA0CA,MAAE,KAAK,MACrDA,MAAE,MAAM;AAAA,IACNA,MAAE;AAAA,IACFA,MAAE;AAAA,IACFA,MAAE;AAAA,IACFA,MAAE;AAAA,IACFA,MAAE,MAAM;AAAA,IACR;AAAA;AAIJ,SAAOA,MAAE,OAAO;AAAA;AAQlB,MAAM,2BAA2B,CAAC,SAAS;4BAGzC,SACyB;AACzB,QAAM,EAAE,QAAQ,WAAW;AAE3B,QAAM,gBAAgBA,MAAE,OAAO;AAAA,IAC7B,MAAMA,MAAE,SAAS,QAAQ;AAAA,IACzB,SAAS,iBAAiB;AAAA,IAC1B,OAAOA,MAAE,MAAMA,MAAE,UAAU;AAAA,IAC3B,YAAYA,MAAE,SAAS;AAAA;AAGzB,QAAM,kBAAkB,CAAC,EAAE,YAAY;AAAkC,OACpE;AAAA,IACH,SAAS,QAAQ,OAAO,YAAU;AAChC,YAAM,WAAW,IAAI,IAAI,OAAO,SAAS,UAAU,uBAChD;AACH,YAAM,YAAY,yBAAyB,SAAS;AACpD,UAAI,CAAC,WAAW;AACd,eAAO,KACL,+BAA+B,OAAO,SAAS,gCAAgC;AAAA;AAGnF,aAAO;AAAA;AAAA;AAIX,QAAM,SAASC;AACf,SAAO,IACL,UACA,OAAO,KAAsB,QAA2C;AA9E5E;AA+EM,UAAM,cAAc,cAAc,UAAU,IAAI;AAEhD,QAAI,CAAC,YAAY,SAAS;AACxB,YAAM,IAAIC,kBAAW,yBAAyB,YAAY;AAAA;AAG5D,UAAM,QAAQ,YAAY;AAE1B,WAAO,KACL,kCACE,MAAM,kBACM,KAAK,UAAU,MAAM,mBACjC,MAAM,QAAQ,MAAM,MAAM,KAAK,OAAO,kBACxB,YAAM,eAAN,YAAoB;AAGtC,QAAI;AACF,YAAM,YAAY,wCAAc,MAAM;AAEtC,UAAI,KAAK,gBAAgB;AAAA,aAClB,KAAP;AACA,YAAM,IAAI,MACR,oDAAoD;AAAA;AAAA;AAM5D,SAAO,IAAIC;AAEX,SAAO;AAAA;;;;"}
|
|
1
|
+
{"version":3,"file":"index.cjs.js","sources":["../src/service/AuthorizedSearchEngine.ts","../src/service/router.ts"],"sourcesContent":["/*\n * Copyright 2022 The Backstage Authors\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { compact, zipObject } from 'lodash';\nimport qs from 'qs';\nimport DataLoader from 'dataloader';\nimport {\n AuthorizeDecision,\n AuthorizeQuery,\n AuthorizeResult,\n PermissionAuthorizer,\n} from '@backstage/plugin-permission-common';\nimport {\n DocumentTypeInfo,\n IndexableDocument,\n QueryRequestOptions,\n QueryTranslator,\n SearchEngine,\n SearchQuery,\n SearchResult,\n SearchResultSet,\n} from '@backstage/search-common';\nimport { Config } from '@backstage/config';\nimport { InputError } from '@backstage/errors';\n\nexport function decodePageCursor(pageCursor?: string): { page: number } {\n if (!pageCursor) {\n return { page: 0 };\n }\n\n const page = Number(Buffer.from(pageCursor, 'base64').toString('utf-8'));\n if (isNaN(page)) {\n throw new InputError('Invalid page cursor');\n }\n\n if (page < 0) {\n throw new InputError('Invalid page cursor');\n }\n\n return {\n page,\n };\n}\n\nexport function encodePageCursor({ page }: { page: number }): string {\n return Buffer.from(`${page}`, 'utf-8').toString('base64');\n}\n\nexport class AuthorizedSearchEngine implements SearchEngine {\n private readonly pageSize = 25;\n private readonly queryLatencyBudgetMs: number;\n\n constructor(\n private readonly searchEngine: SearchEngine,\n private readonly types: Record<string, DocumentTypeInfo>,\n private readonly permissions: PermissionAuthorizer,\n config: Config,\n ) {\n this.queryLatencyBudgetMs =\n config.getOptionalNumber('search.permissions.queryLatencyBudgetMs') ??\n 1000;\n }\n\n setTranslator(translator: QueryTranslator): void {\n this.searchEngine.setTranslator(translator);\n }\n\n async index(type: string, documents: IndexableDocument[]): Promise<void> {\n this.searchEngine.index(type, documents);\n }\n\n async query(\n query: SearchQuery,\n options: QueryRequestOptions,\n ): Promise<SearchResultSet> {\n const queryStartTime = Date.now();\n\n const authorizer = new DataLoader(\n (requests: readonly AuthorizeQuery[]) =>\n this.permissions.authorize(requests.slice(), options),\n {\n // Serialize the permission name and resourceRef as\n // a query string to avoid collisions from overlapping\n // permission names and resourceRefs.\n cacheKeyFn: ({ permission: { name }, resourceRef }) =>\n qs.stringify({ name, resourceRef }),\n },\n );\n const requestedTypes = query.types || Object.keys(this.types);\n\n const typeDecisions = zipObject(\n requestedTypes,\n await Promise.all(\n requestedTypes.map(type => {\n const permission = this.types[type]?.visibilityPermission;\n\n return permission\n ? authorizer.load({ permission })\n : { result: AuthorizeResult.ALLOW as const };\n }),\n ),\n );\n\n const authorizedTypes = requestedTypes.filter(\n type => typeDecisions[type]?.result !== AuthorizeResult.DENY,\n );\n\n const resultByResultFilteringRequired = authorizedTypes.some(\n type => typeDecisions[type]?.result === AuthorizeResult.CONDITIONAL,\n );\n\n // When there are no CONDITIONAL decisions for any of the requested\n // result types, we can skip filtering result by result by simply\n // skipping the types the user is not permitted to see, which will\n // be much more efficient.\n //\n // Since it's not currently possible to configure the page size used\n // by search engines, this detail means that a single user might see\n // a different page size depending on whether their search required\n // result-by-result filtering or not. We can fix this minor\n // inconsistency by introducing a configurable page size.\n //\n // cf. https://github.com/backstage/backstage/issues/9162\n if (!resultByResultFilteringRequired) {\n return this.searchEngine.query(\n { ...query, types: authorizedTypes },\n options,\n );\n }\n\n const { page } = decodePageCursor(query.pageCursor);\n const targetResults = (page + 1) * this.pageSize;\n\n let filteredResults: SearchResult[] = [];\n let nextPageCursor: string | undefined;\n let latencyBudgetExhausted = false;\n\n do {\n const nextPage = await this.searchEngine.query(\n { ...query, types: authorizedTypes, pageCursor: nextPageCursor },\n options,\n );\n\n filteredResults = filteredResults.concat(\n await this.filterResults(nextPage.results, typeDecisions, authorizer),\n );\n\n nextPageCursor = nextPage.nextPageCursor;\n latencyBudgetExhausted =\n Date.now() - queryStartTime > this.queryLatencyBudgetMs;\n } while (\n nextPageCursor &&\n filteredResults.length < targetResults &&\n !latencyBudgetExhausted\n );\n\n return {\n results: filteredResults.slice(\n page * this.pageSize,\n (page + 1) * this.pageSize,\n ),\n previousPageCursor:\n page === 0 ? undefined : encodePageCursor({ page: page - 1 }),\n nextPageCursor:\n !latencyBudgetExhausted &&\n (nextPageCursor || filteredResults.length > targetResults)\n ? encodePageCursor({ page: page + 1 })\n : undefined,\n };\n }\n\n private async filterResults(\n results: SearchResult[],\n typeDecisions: Record<string, AuthorizeDecision>,\n authorizer: DataLoader<AuthorizeQuery, AuthorizeDecision>,\n ) {\n return compact(\n await Promise.all(\n results.map(result => {\n if (typeDecisions[result.type]?.result === AuthorizeResult.ALLOW) {\n return result;\n }\n\n const permission = this.types[result.type]?.visibilityPermission;\n const resourceRef = result.document.authorization?.resourceRef;\n\n if (!permission || !resourceRef) {\n return result;\n }\n\n return authorizer\n .load({ permission, resourceRef })\n .then(decision =>\n decision.result === AuthorizeResult.ALLOW ? result : undefined,\n );\n }),\n ),\n );\n }\n}\n","/*\n * Copyright 2021 The Backstage Authors\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport express from 'express';\nimport Router from 'express-promise-router';\nimport { Logger } from 'winston';\nimport { z } from 'zod';\nimport { errorHandler } from '@backstage/backend-common';\nimport { InputError } from '@backstage/errors';\nimport { Config } from '@backstage/config';\nimport { JsonObject, JsonValue } from '@backstage/types';\nimport { IdentityClient } from '@backstage/plugin-auth-backend';\nimport { PermissionAuthorizer } from '@backstage/plugin-permission-common';\nimport { DocumentTypeInfo, SearchResultSet } from '@backstage/search-common';\nimport { SearchEngine } from '@backstage/plugin-search-backend-node';\nimport { AuthorizedSearchEngine } from './AuthorizedSearchEngine';\n\nconst jsonObjectSchema: z.ZodSchema<JsonObject> = z.lazy(() => {\n const jsonValueSchema: z.ZodSchema<JsonValue> = z.lazy(() =>\n z.union([\n z.string(),\n z.number(),\n z.boolean(),\n z.null(),\n z.array(jsonValueSchema),\n jsonObjectSchema,\n ]),\n );\n\n return z.record(jsonValueSchema);\n});\n\nexport type RouterOptions = {\n engine: SearchEngine;\n types: Record<string, DocumentTypeInfo>;\n permissions: PermissionAuthorizer;\n config: Config;\n logger: Logger;\n};\n\nconst allowedLocationProtocols = ['http:', 'https:'];\n\nexport async function createRouter(\n options: RouterOptions,\n): Promise<express.Router> {\n const { engine: inputEngine, types, permissions, config, logger } = options;\n\n const requestSchema = z.object({\n term: z.string().default(''),\n filters: jsonObjectSchema.optional(),\n types: z\n .array(z.string().refine(type => Object.keys(types).includes(type)))\n .optional(),\n pageCursor: z.string().optional(),\n });\n\n const engine = config.getOptionalBoolean('permission.enabled')\n ? new AuthorizedSearchEngine(inputEngine, types, permissions, config)\n : inputEngine;\n\n const filterResultSet = ({ results, ...resultSet }: SearchResultSet) => ({\n ...resultSet,\n results: results.filter(result => {\n const protocol = new URL(result.document.location, 'https://example.com')\n .protocol;\n const isAllowed = allowedLocationProtocols.includes(protocol);\n if (!isAllowed) {\n logger.info(\n `Rejected search result for \"${result.document.title}\" as location protocol \"${protocol}\" is unsafe`,\n );\n }\n return isAllowed;\n }),\n });\n\n const router = Router();\n router.get(\n '/query',\n async (req: express.Request, res: express.Response<SearchResultSet>) => {\n const parseResult = requestSchema.safeParse(req.query);\n\n if (!parseResult.success) {\n throw new InputError(`Invalid query string: ${parseResult.error}`);\n }\n\n const query = parseResult.data;\n\n logger.info(\n `Search request received: term=\"${\n query.term\n }\", filters=${JSON.stringify(query.filters)}, types=${\n query.types ? query.types.join(',') : ''\n }, pageCursor=${query.pageCursor ?? ''}`,\n );\n\n const token = IdentityClient.getBearerToken(req.header('authorization'));\n\n try {\n const resultSet = await engine?.query(query, { token });\n\n res.send(filterResultSet(resultSet));\n } catch (err) {\n throw new Error(\n `There was a problem performing the search query. ${err}`,\n );\n }\n },\n );\n\n router.use(errorHandler());\n\n return router;\n}\n"],"names":["InputError","DataLoader","qs","zipObject","AuthorizeResult","compact","z","Router","IdentityClient","errorHandler"],"mappings":"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"}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import express from 'express';
|
|
2
2
|
import { Logger } from 'winston';
|
|
3
|
+
import { Config } from '@backstage/config';
|
|
4
|
+
import { PermissionAuthorizer } from '@backstage/plugin-permission-common';
|
|
5
|
+
import { DocumentTypeInfo } from '@backstage/search-common';
|
|
3
6
|
import { SearchEngine } from '@backstage/plugin-search-backend-node';
|
|
4
7
|
|
|
5
8
|
declare type RouterOptions = {
|
|
6
9
|
engine: SearchEngine;
|
|
10
|
+
types: Record<string, DocumentTypeInfo>;
|
|
11
|
+
permissions: PermissionAuthorizer;
|
|
12
|
+
config: Config;
|
|
7
13
|
logger: Logger;
|
|
8
14
|
};
|
|
9
15
|
declare function createRouter(options: RouterOptions): Promise<express.Router>;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@backstage/plugin-search-backend",
|
|
3
3
|
"description": "The Backstage backend plugin that provides your backstage app with search",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.4.1",
|
|
5
5
|
"main": "dist/index.cjs.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
7
7
|
"license": "Apache-2.0",
|
|
@@ -20,26 +20,32 @@
|
|
|
20
20
|
"clean": "backstage-cli clean"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@backstage/backend-common": "^0.10.
|
|
23
|
+
"@backstage/backend-common": "^0.10.6",
|
|
24
24
|
"@backstage/config": "^0.1.13",
|
|
25
25
|
"@backstage/errors": "^0.2.0",
|
|
26
|
-
"@backstage/plugin-
|
|
27
|
-
"@backstage/
|
|
26
|
+
"@backstage/plugin-auth-backend": "^0.9.0",
|
|
27
|
+
"@backstage/plugin-permission-common": "^0.4.0-next.0",
|
|
28
|
+
"@backstage/plugin-permission-node": "^0.4.2",
|
|
29
|
+
"@backstage/plugin-search-backend-node": "^0.4.5",
|
|
30
|
+
"@backstage/search-common": "^0.2.2",
|
|
28
31
|
"@backstage/types": "^0.1.1",
|
|
29
32
|
"@types/express": "^4.17.6",
|
|
33
|
+
"dataloader": "^2.0.0",
|
|
30
34
|
"express": "^4.17.1",
|
|
31
35
|
"express-promise-router": "^4.1.0",
|
|
36
|
+
"lodash": "^4.17.21",
|
|
37
|
+
"qs": "^6.10.1",
|
|
32
38
|
"winston": "^3.2.1",
|
|
33
39
|
"yn": "^4.0.0",
|
|
34
40
|
"zod": "^3.11.6"
|
|
35
41
|
},
|
|
36
42
|
"devDependencies": {
|
|
37
|
-
"@backstage/cli": "^0.
|
|
43
|
+
"@backstage/cli": "^0.13.1",
|
|
38
44
|
"@types/supertest": "^2.0.8",
|
|
39
45
|
"supertest": "^6.1.3"
|
|
40
46
|
},
|
|
41
47
|
"files": [
|
|
42
48
|
"dist"
|
|
43
49
|
],
|
|
44
|
-
"gitHead": "
|
|
50
|
+
"gitHead": "f944a625c4a8ec7f6a6237502691da9209ce6b14"
|
|
45
51
|
}
|