@red-hat-developer-hub/backstage-plugin-scorecard-backend 1.0.0

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 ADDED
@@ -0,0 +1,32 @@
1
+ # @red-hat-developer-hub/backstage-plugin-scorecard-backend
2
+
3
+ ## 1.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - 6709132: Release the major version of Scorecard plugin
8
+
9
+ ### Patch Changes
10
+
11
+ - 26ffba9: Fixed a bug which caused users without entity read access to still be able to view the scorecard metrics for this entity.
12
+ - Updated dependencies [6709132]
13
+ - @red-hat-developer-hub/backstage-plugin-scorecard-common@1.0.0
14
+ - @red-hat-developer-hub/backstage-plugin-scorecard-node@1.0.0
15
+
16
+ ## 0.2.0
17
+
18
+ ### Minor Changes
19
+
20
+ - 0478b79: Unify provider IDs
21
+
22
+ ## 0.1.0
23
+
24
+ ### Minor Changes
25
+
26
+ - b5ec15c: First version of Scorecard plugin
27
+
28
+ ### Patch Changes
29
+
30
+ - Updated dependencies [b5ec15c]
31
+ - @red-hat-developer-hub/backstage-plugin-scorecard-common@0.1.0
32
+ - @red-hat-developer-hub/backstage-plugin-scorecard-node@0.1.0
package/README.md ADDED
@@ -0,0 +1,106 @@
1
+ # Scorecard backend plugin
2
+
3
+ Provides customizable metrics and scoring capabilities from various data sources for software components in the Backstage catalog.
4
+
5
+ ## Installation
6
+
7
+ This plugin is installed via the `@red-hat-developer-hub/backstage-plugin-scorecard-backend` package. To install it to your backend package, run the following command:
8
+
9
+ ```bash
10
+ # From your root directory
11
+ yarn --cwd packages/backend add @red-hat-developer-hub/backstage-plugin-scorecard-backend
12
+ ```
13
+
14
+ Then add the plugin to your backend in `packages/backend/src/index.ts`:
15
+
16
+ ```ts
17
+ const backend = createBackend();
18
+ // ...
19
+ backend.add(
20
+ import('@red-hat-developer-hub/backstage-plugin-scorecard-backend'),
21
+ );
22
+ ```
23
+
24
+ ## RBAC permissions
25
+
26
+ Scorecard plugin provides the following permissions:
27
+
28
+ | Name | Resource Type | Policy | Description | Requirements |
29
+ | --------------------- | ---------------- | ------ | ----------------------------------------- | ------------------- |
30
+ | scorecard.metric.read | scorecard-metric | read | Allows the user to read scorecard metrics | catalog.entity.read |
31
+
32
+ ### `scorecard.metric.read`
33
+
34
+ - **Description**: Allows the user to read scorecard metrics
35
+ - **Resource Type**: `scorecard-metric`
36
+ - **Action**: `read`
37
+
38
+ This permission controls access to viewing scorecard metrics for entities.
39
+
40
+ #### Condition `HAS_METRIC_ID`
41
+
42
+ - Optionally allow access to only specific metrics by their identifiers.
43
+
44
+ **Example RBAC policies file configuration:**
45
+
46
+ ```csv rbac-policy.csv
47
+ g, user:default/<YOUR_USERNAME>, role:default/scorecard-viewer
48
+ p, role:default/scorecard-viewer, scorecard.metric.read, read, allow
49
+ ```
50
+
51
+ **Example RBAC conditional policies file configuration:**
52
+
53
+ ```YAML rbac-conditions.yaml
54
+ ---
55
+ result: CONDITIONAL
56
+ roleEntityRef: "role:default/scorecard-viewer"
57
+ pluginId: scorecard
58
+ resourceType: scorecard-metric
59
+ permissionMapping:
60
+ - read
61
+ conditions:
62
+ rule: HAS_METRIC_ID
63
+ resourceType: scorecard-metric
64
+ params:
65
+ metricIds: ['github.open_prs']
66
+ ```
67
+
68
+ This policy would allow users to read only the GitHub Open PRs metric, while restricting access to other available metrics.
69
+
70
+ ## Metric Providers
71
+
72
+ The Scorecard plugin collects metrics from third-party data sources using metric providers. The Scorecard node plugin provides `scorecardMetricsExtensionPoint` extension point that is used to connect your backend plugin module that exports custom metrics via metric providers to the Scorecard backend plugin. For detailed information on creating metric providers, see [providers.md](./docs/providers.md).
73
+
74
+ ### Available Metric Providers
75
+
76
+ The following metric providers are available:
77
+
78
+ | Provider | Metric ID | Title | Description | Type |
79
+ | ---------- | ------------------ | ---------------- | ------------------------------------- | ------ |
80
+ | **GitHub** | `github.open_prs` | GitHub open PRs | Count of open Pull Requests in GitHub | number |
81
+ | **Jira** | `jira.open_issues` | Jira open issues | The number of opened issues in Jira | number |
82
+
83
+ To use these providers, install the corresponding backend modules:
84
+
85
+ - GitHub: [`@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-github`](../scorecard-backend-module-github/README.md)
86
+ - Jira: [`@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-jira`](../scorecard-backend-module-jira/README.md)
87
+
88
+ ## Thresholds
89
+
90
+ Thresholds define conditions that determine which category a metric value belongs to (`error`, `warning`, or `success`). The Scorecard plugin provides multiple ways to configure thresholds:
91
+
92
+ - **Provider Defaults**: Metric providers define default thresholds
93
+ - **App Configuration**: Override defaults through `app-config.yaml`
94
+ - **Entity Annotations**: Override specific thresholds per entity using catalog annotations
95
+
96
+ Thresholds are evaluated in order, and the first matching rule determines the category. The plugin supports various operators for number metrics (`>`, `>=`, `<`, `<=`, `==`, `!=`, `-` (range)) and boolean metrics (`==`, `!=`).
97
+
98
+ For comprehensive threshold configuration guide, examples, and best practices, see [thresholds.md](./docs/thresholds.md).
99
+
100
+ ## Development
101
+
102
+ This plugin backend can be started in a standalone mode from directly in this
103
+ package with `yarn start`. It is a limited setup that is most convenient when
104
+ developing the plugin backend itself.
105
+
106
+ If you want to run the entire project, including the frontend, run `yarn start` from the root directory.
@@ -0,0 +1,10 @@
1
+ 'use strict';
2
+
3
+ Object.defineProperty(exports, '__esModule', { value: true });
4
+
5
+ var plugin = require('./plugin.cjs.js');
6
+
7
+
8
+
9
+ exports.default = plugin.scorecardPlugin;
10
+ //# sourceMappingURL=index.cjs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.cjs.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;"}
@@ -0,0 +1,10 @@
1
+ import * as _backstage_backend_plugin_api from '@backstage/backend-plugin-api';
2
+
3
+ /**
4
+ * scorecardPlugin backend plugin
5
+ *
6
+ * @public
7
+ */
8
+ declare const scorecardPlugin: _backstage_backend_plugin_api.BackendFeature;
9
+
10
+ export { scorecardPlugin as default };
@@ -0,0 +1,42 @@
1
+ 'use strict';
2
+
3
+ var pluginPermissionCommon = require('@backstage/plugin-permission-common');
4
+ var errors = require('@backstage/errors');
5
+ var alpha = require('@backstage/plugin-catalog-common/alpha');
6
+ var rules = require('./rules.cjs.js');
7
+
8
+ const checkEntityAccess = async (entityRef, req, permissions, httpAuth) => {
9
+ const entityAccessDecision = await permissions.authorize(
10
+ [{ permission: alpha.catalogEntityReadPermission, resourceRef: entityRef }],
11
+ { credentials: await httpAuth.credentials(req) }
12
+ );
13
+ if (entityAccessDecision[0].result !== pluginPermissionCommon.AuthorizeResult.ALLOW) {
14
+ throw new errors.NotAllowedError("Access to entity metrics denied");
15
+ }
16
+ };
17
+ const matches = (metric, filters) => {
18
+ if (!filters) {
19
+ return true;
20
+ }
21
+ if ("allOf" in filters) {
22
+ return filters.allOf.every((filter) => matches(metric, filter));
23
+ }
24
+ if ("anyOf" in filters) {
25
+ return filters.anyOf.some((filter) => matches(metric, filter));
26
+ }
27
+ if ("not" in filters) {
28
+ return !matches(metric, filters.not);
29
+ }
30
+ return Object.values(rules.rules).find((r) => r.name === filters.rule)?.apply(metric, filters.params ?? {}) ?? false;
31
+ };
32
+ const filterAuthorizedMetrics = (metrics, filter) => {
33
+ if (!filter) {
34
+ return metrics;
35
+ }
36
+ return metrics.filter((metric) => matches(metric, filter));
37
+ };
38
+
39
+ exports.checkEntityAccess = checkEntityAccess;
40
+ exports.filterAuthorizedMetrics = filterAuthorizedMetrics;
41
+ exports.matches = matches;
42
+ //# sourceMappingURL=permissionUtils.cjs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"permissionUtils.cjs.js","sources":["../../src/permissions/permissionUtils.ts"],"sourcesContent":["/*\n * Copyright Red Hat, Inc.\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 {\n PermissionCondition,\n PermissionCriteria,\n PermissionRuleParams,\n AuthorizeResult,\n} from '@backstage/plugin-permission-common';\nimport { Request } from 'express';\nimport { NotAllowedError } from '@backstage/errors';\nimport { catalogEntityReadPermission } from '@backstage/plugin-catalog-common/alpha';\nimport type {\n HttpAuthService,\n PermissionsService,\n} from '@backstage/backend-plugin-api';\n\nimport { Metric } from '@red-hat-developer-hub/backstage-plugin-scorecard-common';\n\nimport { rules as scorecardRules } from './rules';\n\nexport const checkEntityAccess = async (\n entityRef: string,\n req: Request,\n permissions: PermissionsService,\n httpAuth: HttpAuthService,\n): Promise<void> => {\n const entityAccessDecision = await permissions.authorize(\n [{ permission: catalogEntityReadPermission, resourceRef: entityRef }],\n { credentials: await httpAuth.credentials(req) },\n );\n\n if (entityAccessDecision[0].result !== AuthorizeResult.ALLOW) {\n throw new NotAllowedError('Access to entity metrics denied');\n }\n};\n\nexport const matches = (\n metric: Metric,\n filters?: PermissionCriteria<\n PermissionCondition<string, PermissionRuleParams>\n >,\n): boolean => {\n if (!filters) {\n return true;\n }\n\n if ('allOf' in filters) {\n return filters.allOf.every(filter => matches(metric, filter));\n }\n\n if ('anyOf' in filters) {\n return filters.anyOf.some(filter => matches(metric, filter));\n }\n\n if ('not' in filters) {\n return !matches(metric, filters.not);\n }\n return (\n Object.values(scorecardRules)\n .find(r => r.name === filters.rule)\n ?.apply(metric, filters.params ?? {}) ?? false\n );\n};\n\nexport const filterAuthorizedMetrics = (\n metrics: Metric[],\n filter?: PermissionCriteria<\n PermissionCondition<string, PermissionRuleParams>\n >,\n) => {\n if (!filter) {\n return metrics;\n }\n\n return metrics.filter(metric => matches(metric, filter));\n};\n"],"names":["catalogEntityReadPermission","AuthorizeResult","NotAllowedError","scorecardRules"],"mappings":";;;;;;;AAkCO,MAAM,iBAAoB,GAAA,OAC/B,SACA,EAAA,GAAA,EACA,aACA,QACkB,KAAA;AAClB,EAAM,MAAA,oBAAA,GAAuB,MAAM,WAAY,CAAA,SAAA;AAAA,IAC7C,CAAC,EAAE,UAAA,EAAYA,iCAA6B,EAAA,WAAA,EAAa,WAAW,CAAA;AAAA,IACpE,EAAE,WAAa,EAAA,MAAM,QAAS,CAAA,WAAA,CAAY,GAAG,CAAE;AAAA,GACjD;AAEA,EAAA,IAAI,oBAAqB,CAAA,CAAC,CAAE,CAAA,MAAA,KAAWC,uCAAgB,KAAO,EAAA;AAC5D,IAAM,MAAA,IAAIC,uBAAgB,iCAAiC,CAAA;AAAA;AAE/D;AAEa,MAAA,OAAA,GAAU,CACrB,MAAA,EACA,OAGY,KAAA;AACZ,EAAA,IAAI,CAAC,OAAS,EAAA;AACZ,IAAO,OAAA,IAAA;AAAA;AAGT,EAAA,IAAI,WAAW,OAAS,EAAA;AACtB,IAAA,OAAO,QAAQ,KAAM,CAAA,KAAA,CAAM,YAAU,OAAQ,CAAA,MAAA,EAAQ,MAAM,CAAC,CAAA;AAAA;AAG9D,EAAA,IAAI,WAAW,OAAS,EAAA;AACtB,IAAA,OAAO,QAAQ,KAAM,CAAA,IAAA,CAAK,YAAU,OAAQ,CAAA,MAAA,EAAQ,MAAM,CAAC,CAAA;AAAA;AAG7D,EAAA,IAAI,SAAS,OAAS,EAAA;AACpB,IAAA,OAAO,CAAC,OAAA,CAAQ,MAAQ,EAAA,OAAA,CAAQ,GAAG,CAAA;AAAA;AAErC,EAAA,OACE,OAAO,MAAO,CAAAC,WAAc,CACzB,CAAA,IAAA,CAAK,OAAK,CAAE,CAAA,IAAA,KAAS,OAAQ,CAAA,IAAI,GAChC,KAAM,CAAA,MAAA,EAAQ,QAAQ,MAAU,IAAA,EAAE,CAAK,IAAA,KAAA;AAE/C;AAEa,MAAA,uBAAA,GAA0B,CACrC,OAAA,EACA,MAGG,KAAA;AACH,EAAA,IAAI,CAAC,MAAQ,EAAA;AACX,IAAO,OAAA,OAAA;AAAA;AAGT,EAAA,OAAO,QAAQ,MAAO,CAAA,CAAA,MAAA,KAAU,OAAQ,CAAA,MAAA,EAAQ,MAAM,CAAC,CAAA;AACzD;;;;;;"}
@@ -0,0 +1,27 @@
1
+ 'use strict';
2
+
3
+ var pluginPermissionNode = require('@backstage/plugin-permission-node');
4
+ var zod = require('zod');
5
+ var backstagePluginScorecardCommon = require('@red-hat-developer-hub/backstage-plugin-scorecard-common');
6
+
7
+ const scorecardMetricPermissionResourceRef = pluginPermissionNode.createPermissionResourceRef().with({
8
+ pluginId: "scorecard",
9
+ resourceType: backstagePluginScorecardCommon.RESOURCE_TYPE_SCORECARD_METRIC
10
+ });
11
+ const hasMetricId = pluginPermissionNode.createPermissionRule({
12
+ name: "HAS_METRIC_ID",
13
+ description: "Should allow users to access metrics with specified metric IDs",
14
+ resourceRef: scorecardMetricPermissionResourceRef,
15
+ paramsSchema: zod.z.object({
16
+ metricIds: zod.z.string().array().optional().describe("List of metric IDs to match on")
17
+ }),
18
+ apply: (metric, { metricIds }) => {
19
+ return metricIds && metricIds.length > 0 ? metricIds.includes(metric.id) : true;
20
+ },
21
+ toQuery: ({ metricIds }) => ({ key: "metricId", values: metricIds })
22
+ });
23
+ const rules = { hasMetricId };
24
+
25
+ exports.rules = rules;
26
+ exports.scorecardMetricPermissionResourceRef = scorecardMetricPermissionResourceRef;
27
+ //# sourceMappingURL=rules.cjs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rules.cjs.js","sources":["../../src/permissions/rules.ts"],"sourcesContent":["/*\n * Copyright Red Hat, Inc.\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 {\n createPermissionResourceRef,\n createPermissionRule,\n} from '@backstage/plugin-permission-node';\nimport { z } from 'zod';\nimport {\n Metric,\n RESOURCE_TYPE_SCORECARD_METRIC,\n} from '@red-hat-developer-hub/backstage-plugin-scorecard-common';\n\nexport type ScorecardFilter = {\n key: string;\n values: Array<string> | undefined;\n};\n\nexport type ScorecardFilters =\n | { anyOf: ScorecardFilters[] }\n | { allOf: ScorecardFilters[] }\n | { not: ScorecardFilters }\n | ScorecardFilter;\n\nexport const scorecardMetricPermissionResourceRef = createPermissionResourceRef<\n Metric,\n ScorecardFilter\n>().with({\n pluginId: 'scorecard',\n resourceType: RESOURCE_TYPE_SCORECARD_METRIC,\n});\n\nconst hasMetricId = createPermissionRule({\n name: 'HAS_METRIC_ID',\n description: 'Should allow users to access metrics with specified metric IDs',\n resourceRef: scorecardMetricPermissionResourceRef,\n\n paramsSchema: z.object({\n metricIds: z\n .string()\n .array()\n .optional()\n .describe('List of metric IDs to match on'),\n }),\n apply: (metric: Metric, { metricIds }) => {\n return metricIds && metricIds.length > 0\n ? metricIds.includes(metric.id)\n : true;\n },\n toQuery: ({ metricIds }) => ({ key: 'metricId', values: metricIds }),\n});\n\nexport const rules = { hasMetricId };\n"],"names":["createPermissionResourceRef","RESOURCE_TYPE_SCORECARD_METRIC","createPermissionRule","z"],"mappings":";;;;;;AAqCa,MAAA,oCAAA,GAAuCA,gDAGlD,EAAA,CAAE,IAAK,CAAA;AAAA,EACP,QAAU,EAAA,WAAA;AAAA,EACV,YAAc,EAAAC;AAChB,CAAC;AAED,MAAM,cAAcC,yCAAqB,CAAA;AAAA,EACvC,IAAM,EAAA,eAAA;AAAA,EACN,WAAa,EAAA,gEAAA;AAAA,EACb,WAAa,EAAA,oCAAA;AAAA,EAEb,YAAA,EAAcC,MAAE,MAAO,CAAA;AAAA,IACrB,SAAA,EAAWA,MACR,MAAO,EAAA,CACP,OACA,CAAA,QAAA,EACA,CAAA,QAAA,CAAS,gCAAgC;AAAA,GAC7C,CAAA;AAAA,EACD,KAAO,EAAA,CAAC,MAAgB,EAAA,EAAE,WAAgB,KAAA;AACxC,IAAO,OAAA,SAAA,IAAa,UAAU,MAAS,GAAA,CAAA,GACnC,UAAU,QAAS,CAAA,MAAA,CAAO,EAAE,CAC5B,GAAA,IAAA;AAAA,GACN;AAAA,EACA,OAAA,EAAS,CAAC,EAAE,SAAA,QAAiB,EAAE,GAAA,EAAK,UAAY,EAAA,MAAA,EAAQ,SAAU,EAAA;AACpE,CAAC,CAAA;AAEY,MAAA,KAAA,GAAQ,EAAE,WAAY;;;;;"}
@@ -0,0 +1,69 @@
1
+ 'use strict';
2
+
3
+ var backendPluginApi = require('@backstage/backend-plugin-api');
4
+ var router = require('./service/router.cjs.js');
5
+ var pluginCatalogNode = require('@backstage/plugin-catalog-node');
6
+ var backstagePluginScorecardNode = require('@red-hat-developer-hub/backstage-plugin-scorecard-node');
7
+ var MetricProvidersRegistry = require('./providers/MetricProvidersRegistry.cjs.js');
8
+ var CatalogMetricService = require('./service/CatalogMetricService.cjs.js');
9
+ var catalogClient = require('@backstage/catalog-client');
10
+ var ThresholdEvaluator = require('./threshold/ThresholdEvaluator.cjs.js');
11
+ var backstagePluginScorecardCommon = require('@red-hat-developer-hub/backstage-plugin-scorecard-common');
12
+ var rules = require('./permissions/rules.cjs.js');
13
+
14
+ const scorecardPlugin = backendPluginApi.createBackendPlugin({
15
+ pluginId: "scorecard",
16
+ register(env) {
17
+ const metricProvidersRegistry = new MetricProvidersRegistry.MetricProvidersRegistry();
18
+ env.registerExtensionPoint(backstagePluginScorecardNode.scorecardMetricsExtensionPoint, {
19
+ addMetricProvider(...newMetricProviders) {
20
+ newMetricProviders.forEach((metricProvider) => {
21
+ metricProvidersRegistry.register(metricProvider);
22
+ });
23
+ }
24
+ });
25
+ env.registerInit({
26
+ deps: {
27
+ discovery: backendPluginApi.coreServices.discovery,
28
+ auth: backendPluginApi.coreServices.auth,
29
+ httpRouter: backendPluginApi.coreServices.httpRouter,
30
+ catalog: pluginCatalogNode.catalogServiceRef,
31
+ httpAuth: backendPluginApi.coreServices.httpAuth,
32
+ permissions: backendPluginApi.coreServices.permissions,
33
+ permissionsRegistry: backendPluginApi.coreServices.permissionsRegistry
34
+ },
35
+ async init({
36
+ discovery,
37
+ auth,
38
+ httpRouter,
39
+ httpAuth,
40
+ permissions,
41
+ permissionsRegistry
42
+ }) {
43
+ permissionsRegistry.addResourceType({
44
+ resourceRef: rules.scorecardMetricPermissionResourceRef,
45
+ permissions: backstagePluginScorecardCommon.scorecardPermissions,
46
+ rules: Object.values(rules.rules)
47
+ });
48
+ const catalogClient$1 = new catalogClient.CatalogClient({ discoveryApi: discovery });
49
+ const catalogMetricService = new CatalogMetricService.CatalogMetricService({
50
+ catalogApi: catalogClient$1,
51
+ registry: metricProvidersRegistry,
52
+ thresholdEvaluator: new ThresholdEvaluator.ThresholdEvaluator(),
53
+ auth
54
+ });
55
+ httpRouter.use(
56
+ await router.createRouter({
57
+ metricProvidersRegistry,
58
+ catalogMetricService,
59
+ httpAuth,
60
+ permissions
61
+ })
62
+ );
63
+ }
64
+ });
65
+ }
66
+ });
67
+
68
+ exports.scorecardPlugin = scorecardPlugin;
69
+ //# sourceMappingURL=plugin.cjs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plugin.cjs.js","sources":["../src/plugin.ts"],"sourcesContent":["/*\n * Copyright Red Hat, Inc.\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 */\nimport {\n coreServices,\n createBackendPlugin,\n} from '@backstage/backend-plugin-api';\nimport { createRouter } from './service/router';\nimport { catalogServiceRef } from '@backstage/plugin-catalog-node';\nimport {\n MetricProvider,\n scorecardMetricsExtensionPoint,\n} from '@red-hat-developer-hub/backstage-plugin-scorecard-node';\nimport { MetricProvidersRegistry } from './providers/MetricProvidersRegistry';\nimport { CatalogMetricService } from './service/CatalogMetricService';\nimport { CatalogClient } from '@backstage/catalog-client';\nimport { ThresholdEvaluator } from './threshold/ThresholdEvaluator';\nimport { scorecardPermissions } from '@red-hat-developer-hub/backstage-plugin-scorecard-common';\nimport {\n scorecardMetricPermissionResourceRef,\n rules as scorecardRules,\n} from './permissions/rules';\n\n/**\n * scorecardPlugin backend plugin\n *\n * @public\n */\nexport const scorecardPlugin = createBackendPlugin({\n pluginId: 'scorecard',\n register(env) {\n const metricProvidersRegistry = new MetricProvidersRegistry();\n\n env.registerExtensionPoint(scorecardMetricsExtensionPoint, {\n addMetricProvider(...newMetricProviders: MetricProvider[]) {\n newMetricProviders.forEach(metricProvider => {\n metricProvidersRegistry.register(metricProvider);\n });\n },\n });\n\n env.registerInit({\n deps: {\n discovery: coreServices.discovery,\n auth: coreServices.auth,\n httpRouter: coreServices.httpRouter,\n catalog: catalogServiceRef,\n httpAuth: coreServices.httpAuth,\n permissions: coreServices.permissions,\n permissionsRegistry: coreServices.permissionsRegistry,\n },\n async init({\n discovery,\n auth,\n httpRouter,\n httpAuth,\n permissions,\n permissionsRegistry,\n }) {\n permissionsRegistry.addResourceType({\n resourceRef: scorecardMetricPermissionResourceRef,\n permissions: scorecardPermissions,\n rules: Object.values(scorecardRules),\n });\n\n const catalogClient = new CatalogClient({ discoveryApi: discovery });\n const catalogMetricService = new CatalogMetricService({\n catalogApi: catalogClient,\n registry: metricProvidersRegistry,\n thresholdEvaluator: new ThresholdEvaluator(),\n auth,\n });\n\n httpRouter.use(\n await createRouter({\n metricProvidersRegistry,\n catalogMetricService,\n httpAuth,\n permissions,\n }),\n );\n },\n });\n },\n});\n"],"names":["createBackendPlugin","MetricProvidersRegistry","scorecardMetricsExtensionPoint","coreServices","catalogServiceRef","scorecardMetricPermissionResourceRef","scorecardPermissions","scorecardRules","catalogClient","CatalogClient","CatalogMetricService","ThresholdEvaluator","createRouter"],"mappings":";;;;;;;;;;;;;AAwCO,MAAM,kBAAkBA,oCAAoB,CAAA;AAAA,EACjD,QAAU,EAAA,WAAA;AAAA,EACV,SAAS,GAAK,EAAA;AACZ,IAAM,MAAA,uBAAA,GAA0B,IAAIC,+CAAwB,EAAA;AAE5D,IAAA,GAAA,CAAI,uBAAuBC,2DAAgC,EAAA;AAAA,MACzD,qBAAqB,kBAAsC,EAAA;AACzD,QAAA,kBAAA,CAAmB,QAAQ,CAAkB,cAAA,KAAA;AAC3C,UAAA,uBAAA,CAAwB,SAAS,cAAc,CAAA;AAAA,SAChD,CAAA;AAAA;AACH,KACD,CAAA;AAED,IAAA,GAAA,CAAI,YAAa,CAAA;AAAA,MACf,IAAM,EAAA;AAAA,QACJ,WAAWC,6BAAa,CAAA,SAAA;AAAA,QACxB,MAAMA,6BAAa,CAAA,IAAA;AAAA,QACnB,YAAYA,6BAAa,CAAA,UAAA;AAAA,QACzB,OAAS,EAAAC,mCAAA;AAAA,QACT,UAAUD,6BAAa,CAAA,QAAA;AAAA,QACvB,aAAaA,6BAAa,CAAA,WAAA;AAAA,QAC1B,qBAAqBA,6BAAa,CAAA;AAAA,OACpC;AAAA,MACA,MAAM,IAAK,CAAA;AAAA,QACT,SAAA;AAAA,QACA,IAAA;AAAA,QACA,UAAA;AAAA,QACA,QAAA;AAAA,QACA,WAAA;AAAA,QACA;AAAA,OACC,EAAA;AACD,QAAA,mBAAA,CAAoB,eAAgB,CAAA;AAAA,UAClC,WAAa,EAAAE,0CAAA;AAAA,UACb,WAAa,EAAAC,mDAAA;AAAA,UACb,KAAA,EAAO,MAAO,CAAA,MAAA,CAAOC,WAAc;AAAA,SACpC,CAAA;AAED,QAAA,MAAMC,kBAAgB,IAAIC,2BAAA,CAAc,EAAE,YAAA,EAAc,WAAW,CAAA;AACnE,QAAM,MAAA,oBAAA,GAAuB,IAAIC,yCAAqB,CAAA;AAAA,UACpD,UAAY,EAAAF,eAAA;AAAA,UACZ,QAAU,EAAA,uBAAA;AAAA,UACV,kBAAA,EAAoB,IAAIG,qCAAmB,EAAA;AAAA,UAC3C;AAAA,SACD,CAAA;AAED,QAAW,UAAA,CAAA,GAAA;AAAA,UACT,MAAMC,mBAAa,CAAA;AAAA,YACjB,uBAAA;AAAA,YACA,oBAAA;AAAA,YACA,QAAA;AAAA,YACA;AAAA,WACD;AAAA,SACH;AAAA;AACF,KACD,CAAA;AAAA;AAEL,CAAC;;;;"}
@@ -0,0 +1,78 @@
1
+ 'use strict';
2
+
3
+ var errors = require('@backstage/errors');
4
+
5
+ class MetricProvidersRegistry {
6
+ metricProviders = /* @__PURE__ */ new Map();
7
+ datasourceIndex = /* @__PURE__ */ new Map();
8
+ register(metricProvider) {
9
+ const providerId = metricProvider.getProviderId();
10
+ const providerDatasource = metricProvider.getProviderDatasourceId();
11
+ const metricId = metricProvider.getMetric().id;
12
+ if (providerId !== metricId) {
13
+ throw new Error(
14
+ `Invalid metric provider with ID ${providerId}, provider ID must match metric ID '${metricId}'`
15
+ );
16
+ }
17
+ const expectedPrefix = `${providerDatasource}.`;
18
+ if (!providerId.startsWith(expectedPrefix) || providerId === expectedPrefix) {
19
+ throw new Error(
20
+ `Invalid metric provider with ID ${providerId}, must have format '${providerDatasource}.<metric_name>' where metric name is not empty`
21
+ );
22
+ }
23
+ if (this.metricProviders.has(providerId)) {
24
+ throw new errors.ConflictError(
25
+ `Metric provider with ID '${providerId}' has already been registered`
26
+ );
27
+ }
28
+ this.metricProviders.set(providerId, metricProvider);
29
+ let datasourceProviders = this.datasourceIndex.get(providerDatasource);
30
+ if (!datasourceProviders) {
31
+ datasourceProviders = /* @__PURE__ */ new Set();
32
+ this.datasourceIndex.set(providerDatasource, datasourceProviders);
33
+ }
34
+ datasourceProviders.add(providerId);
35
+ }
36
+ getProvider(providerId) {
37
+ const metricProvider = this.metricProviders.get(providerId);
38
+ if (!metricProvider) {
39
+ throw new errors.NotFoundError(
40
+ `Metric provider with ID '${providerId}' is not registered.`
41
+ );
42
+ }
43
+ return metricProvider;
44
+ }
45
+ getMetric(providerId) {
46
+ return this.getProvider(providerId).getMetric();
47
+ }
48
+ async calculateMetric(providerId, entity) {
49
+ return this.getProvider(providerId).calculateMetric(entity);
50
+ }
51
+ async calculateMetrics(providerIds, entity) {
52
+ const results = await Promise.allSettled(
53
+ providerIds.map((providerId) => this.calculateMetric(providerId, entity))
54
+ );
55
+ return results.map((result, index) => {
56
+ const providerId = providerIds[index];
57
+ if (result.status === "fulfilled") {
58
+ return { providerId, value: result.value };
59
+ }
60
+ return { providerId, error: result.reason };
61
+ });
62
+ }
63
+ listMetrics() {
64
+ return [...this.metricProviders.values()].map(
65
+ (provider) => provider.getMetric()
66
+ );
67
+ }
68
+ listMetricsByDatasource(datasourceId) {
69
+ const providerIdsOfDatasource = this.datasourceIndex.get(datasourceId);
70
+ if (!providerIdsOfDatasource) {
71
+ return [];
72
+ }
73
+ return Array.from(providerIdsOfDatasource).map((providerId) => this.metricProviders.get(providerId)).filter((provider) => provider !== void 0).map((provider) => provider.getMetric());
74
+ }
75
+ }
76
+
77
+ exports.MetricProvidersRegistry = MetricProvidersRegistry;
78
+ //# sourceMappingURL=MetricProvidersRegistry.cjs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"MetricProvidersRegistry.cjs.js","sources":["../../src/providers/MetricProvidersRegistry.ts"],"sourcesContent":["/*\n * Copyright Red Hat, Inc.\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 { ConflictError, NotFoundError } from '@backstage/errors';\nimport {\n Metric,\n MetricValue,\n} from '@red-hat-developer-hub/backstage-plugin-scorecard-common';\nimport type { Entity } from '@backstage/catalog-model';\nimport { MetricProvider } from '@red-hat-developer-hub/backstage-plugin-scorecard-node';\n\n/**\n * Registry of all registered metric providers.\n */\nexport class MetricProvidersRegistry {\n private readonly metricProviders = new Map<string, MetricProvider>();\n private readonly datasourceIndex = new Map<string, Set<string>>();\n\n register(metricProvider: MetricProvider): void {\n const providerId = metricProvider.getProviderId();\n const providerDatasource = metricProvider.getProviderDatasourceId();\n const metricId = metricProvider.getMetric().id;\n\n if (providerId !== metricId) {\n throw new Error(\n `Invalid metric provider with ID ${providerId}, provider ID must match metric ID '${metricId}'`,\n );\n }\n\n const expectedPrefix = `${providerDatasource}.`;\n if (\n !providerId.startsWith(expectedPrefix) ||\n providerId === expectedPrefix\n ) {\n throw new Error(\n `Invalid metric provider with ID ${providerId}, must have format '${providerDatasource}.<metric_name>' where metric name is not empty`,\n );\n }\n\n if (this.metricProviders.has(providerId)) {\n throw new ConflictError(\n `Metric provider with ID '${providerId}' has already been registered`,\n );\n }\n\n this.metricProviders.set(providerId, metricProvider);\n let datasourceProviders = this.datasourceIndex.get(providerDatasource);\n if (!datasourceProviders) {\n datasourceProviders = new Set();\n this.datasourceIndex.set(providerDatasource, datasourceProviders);\n }\n datasourceProviders.add(providerId);\n }\n\n getProvider(providerId: string): MetricProvider {\n const metricProvider = this.metricProviders.get(providerId);\n if (!metricProvider) {\n throw new NotFoundError(\n `Metric provider with ID '${providerId}' is not registered.`,\n );\n }\n return metricProvider;\n }\n\n getMetric(providerId: string): Metric {\n return this.getProvider(providerId).getMetric();\n }\n\n async calculateMetric(\n providerId: string,\n entity: Entity,\n ): Promise<MetricValue> {\n return this.getProvider(providerId).calculateMetric(entity);\n }\n\n async calculateMetrics(\n providerIds: string[],\n entity: Entity,\n ): Promise<{ providerId: string; value?: MetricValue; error?: Error }[]> {\n const results = await Promise.allSettled(\n providerIds.map(providerId => this.calculateMetric(providerId, entity)),\n );\n\n return results.map((result, index) => {\n const providerId = providerIds[index];\n if (result.status === 'fulfilled') {\n return { providerId, value: result.value };\n }\n return { providerId, error: result.reason as Error };\n });\n }\n\n listMetrics(): Metric[] {\n return [...this.metricProviders.values()].map(provider =>\n provider.getMetric(),\n );\n }\n\n listMetricsByDatasource(datasourceId: string): Metric[] {\n const providerIdsOfDatasource = this.datasourceIndex.get(datasourceId);\n if (!providerIdsOfDatasource) {\n return [];\n }\n\n return Array.from(providerIdsOfDatasource)\n .map(providerId => this.metricProviders.get(providerId))\n .filter((provider): provider is MetricProvider => provider !== undefined)\n .map(provider => provider.getMetric());\n }\n}\n"],"names":["ConflictError","NotFoundError"],"mappings":"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"}
@@ -0,0 +1,169 @@
1
+ 'use strict';
2
+
3
+ var catalogModel = require('@backstage/catalog-model');
4
+ var errors = require('@backstage/errors');
5
+ var backstagePluginScorecardNode = require('@red-hat-developer-hub/backstage-plugin-scorecard-node');
6
+ var permissionUtils = require('../permissions/permissionUtils.cjs.js');
7
+
8
+ class CatalogMetricService {
9
+ catalogApi;
10
+ registry;
11
+ thresholdEvaluator;
12
+ auth;
13
+ constructor(options) {
14
+ this.thresholdEvaluator = options.thresholdEvaluator;
15
+ this.registry = options.registry;
16
+ this.catalogApi = options.catalogApi;
17
+ this.auth = options.auth;
18
+ }
19
+ /**
20
+ * Calculate metric results for a specific catalog entity.
21
+ *
22
+ * @param entityRef - Entity reference in format "kind:namespace/name"
23
+ * @param providerIds - Optional array of provider IDs to calculate.
24
+ * If not provided, calculates all available metrics.
25
+ * @returns Metric results with entity-specific thresholds applied
26
+ */
27
+ async calculateEntityMetrics(entityRef, providerIds, filter) {
28
+ const token = await this.getServiceToken();
29
+ const entity = await this.catalogApi.getEntityByRef(entityRef, token);
30
+ if (!entity) {
31
+ throw new errors.NotFoundError(`Entity not found: ${entityRef}`);
32
+ }
33
+ const metricsToCalculate = providerIds ? this.registry.listMetrics().filter((m) => providerIds.includes(m.id)) : this.registry.listMetrics();
34
+ const supportedMetricsToCalculate = metricsToCalculate.filter((m) => {
35
+ const provider = this.registry.getProvider(m.id);
36
+ return provider.supportsEntity(entity);
37
+ });
38
+ const authorizedMetricsToCalculate = permissionUtils.filterAuthorizedMetrics(
39
+ supportedMetricsToCalculate,
40
+ filter
41
+ );
42
+ const rawResults = await this.registry.calculateMetrics(
43
+ authorizedMetricsToCalculate.map((m) => m.id),
44
+ entity
45
+ );
46
+ return rawResults.map(({ providerId, value, error }, index) => {
47
+ const provider = this.registry.getProvider(providerId);
48
+ const metric = authorizedMetricsToCalculate[index];
49
+ let thresholds;
50
+ let evaluation;
51
+ let thresholdError;
52
+ try {
53
+ thresholds = this.mergeEntityAndProviderThresholds(
54
+ entity,
55
+ provider,
56
+ metric.type
57
+ );
58
+ if (value === void 0) {
59
+ thresholdError = "Unable to evaluate thresholds, metric value is missing";
60
+ } else {
61
+ evaluation = this.thresholdEvaluator.getFirstMatchingThreshold(
62
+ value,
63
+ metric.type,
64
+ thresholds
65
+ );
66
+ }
67
+ } catch (e) {
68
+ thresholdError = errors.stringifyError(e);
69
+ }
70
+ const isMetricCalcError = error || value === void 0;
71
+ return {
72
+ id: metric.id,
73
+ status: isMetricCalcError ? "error" : "success",
74
+ metadata: {
75
+ title: metric.title,
76
+ description: metric.description,
77
+ type: metric.type,
78
+ history: metric.history
79
+ },
80
+ ...isMetricCalcError && {
81
+ error: error ? errors.stringifyError(error) : errors.stringifyError(new Error(`Metric value is 'undefined'`))
82
+ },
83
+ result: {
84
+ value,
85
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
86
+ thresholdResult: {
87
+ definition: thresholds,
88
+ status: thresholdError ? "error" : "success",
89
+ evaluation,
90
+ ...thresholdError && { error: thresholdError }
91
+ }
92
+ }
93
+ };
94
+ });
95
+ }
96
+ async getServiceToken() {
97
+ if (!this.auth) {
98
+ return void 0;
99
+ }
100
+ return await this.auth.getPluginRequestToken({
101
+ onBehalfOf: await this.auth.getOwnServiceCredentials(),
102
+ targetPluginId: "catalog"
103
+ });
104
+ }
105
+ /**
106
+ * Parse threshold overrides from entity annotations.
107
+ * Looks for annotations in the format:
108
+ * scorecard.io/{providerId}.thresholds.rules.{thresholdName}: "{expression}"
109
+ *
110
+ * @param entity - The catalog entity
111
+ * @param providerId - The metric provider ID (e.g., 'jira.open_issues')
112
+ * @param metricType - The metric type
113
+ * @returns Threshold rules from entity annotations, or empty rules if none found or invalid
114
+ */
115
+ parseEntityOverrideThresholds(entity, providerId, metricType) {
116
+ const annotations = entity.metadata?.annotations || {};
117
+ const prefix = `scorecard.io/${providerId}.thresholds.rules.`;
118
+ const overrides = [];
119
+ for (const [annotationKey, expression] of Object.entries(annotations)) {
120
+ if (annotationKey.startsWith(prefix) && expression) {
121
+ const key = annotationKey.substring(prefix.length);
122
+ const entityRule = { key, expression };
123
+ try {
124
+ backstagePluginScorecardNode.validateThresholds({ rules: [entityRule] }, metricType);
125
+ overrides.push(entityRule);
126
+ } catch (e) {
127
+ if (errors.isError(e)) {
128
+ throw new backstagePluginScorecardNode.ThresholdConfigFormatError(
129
+ `Invalid threshold annotation '${annotationKey}: ${expression}' in entity '${catalogModel.stringifyEntityRef(
130
+ entity
131
+ )}': ${e.message}`
132
+ );
133
+ }
134
+ throw e;
135
+ }
136
+ }
137
+ }
138
+ return overrides;
139
+ }
140
+ mergeEntityAndProviderThresholds(entity, provider, metricType) {
141
+ const providerThresholds = provider.getMetricThresholds();
142
+ const entityOverrideThresholds = this.parseEntityOverrideThresholds(
143
+ entity,
144
+ provider.getProviderId(),
145
+ metricType
146
+ );
147
+ const mergedRules = [...providerThresholds.rules];
148
+ for (const override of entityOverrideThresholds) {
149
+ const foundKey = mergedRules.findIndex((rule) => rule.key === override.key);
150
+ if (foundKey !== -1) {
151
+ mergedRules[foundKey] = override;
152
+ } else {
153
+ throw new backstagePluginScorecardNode.ThresholdConfigFormatError(
154
+ `Unable to override ${catalogModel.stringifyEntityRef(
155
+ entity
156
+ )} thresholds by ${JSON.stringify(
157
+ override
158
+ )}, metric provider ${provider.getProviderId()} does not support key ${override.key}`
159
+ );
160
+ }
161
+ }
162
+ return {
163
+ rules: mergedRules
164
+ };
165
+ }
166
+ }
167
+
168
+ exports.CatalogMetricService = CatalogMetricService;
169
+ //# sourceMappingURL=CatalogMetricService.cjs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"CatalogMetricService.cjs.js","sources":["../../src/service/CatalogMetricService.ts"],"sourcesContent":["/*\n * Copyright Red Hat, Inc.\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 { CatalogApi } from '@backstage/catalog-client';\nimport { stringifyEntityRef, type Entity } from '@backstage/catalog-model';\nimport {\n MetricResult,\n MetricType,\n ThresholdConfig,\n ThresholdRule,\n} from '@red-hat-developer-hub/backstage-plugin-scorecard-common';\nimport { MetricProvidersRegistry } from '../providers/MetricProvidersRegistry';\nimport { ThresholdEvaluator } from '../threshold/ThresholdEvaluator';\nimport { isError, NotFoundError, stringifyError } from '@backstage/errors';\nimport {\n MetricProvider,\n ThresholdConfigFormatError,\n validateThresholds,\n} from '@red-hat-developer-hub/backstage-plugin-scorecard-node';\nimport { AuthService } from '@backstage/backend-plugin-api';\nimport { filterAuthorizedMetrics } from '../permissions/permissionUtils';\nimport {\n PermissionCondition,\n PermissionCriteria,\n PermissionRuleParams,\n} from '@backstage/plugin-permission-common';\n\nexport type CatalogMetricServiceOptions = {\n catalogApi: CatalogApi;\n registry: MetricProvidersRegistry;\n thresholdEvaluator: ThresholdEvaluator;\n auth?: AuthService;\n};\n\nexport class CatalogMetricService {\n private readonly catalogApi: CatalogApi;\n private readonly registry: MetricProvidersRegistry;\n private readonly thresholdEvaluator: ThresholdEvaluator;\n private readonly auth?: AuthService;\n\n constructor(options: CatalogMetricServiceOptions) {\n this.thresholdEvaluator = options.thresholdEvaluator;\n this.registry = options.registry;\n this.catalogApi = options.catalogApi;\n this.auth = options.auth;\n }\n\n /**\n * Calculate metric results for a specific catalog entity.\n *\n * @param entityRef - Entity reference in format \"kind:namespace/name\"\n * @param providerIds - Optional array of provider IDs to calculate.\n * If not provided, calculates all available metrics.\n * @returns Metric results with entity-specific thresholds applied\n */\n async calculateEntityMetrics(\n entityRef: string,\n providerIds?: string[],\n filter?: PermissionCriteria<\n PermissionCondition<string, PermissionRuleParams>\n >,\n ): Promise<MetricResult[]> {\n const token = await this.getServiceToken();\n const entity = await this.catalogApi.getEntityByRef(entityRef, token);\n if (!entity) {\n throw new NotFoundError(`Entity not found: ${entityRef}`);\n }\n\n const metricsToCalculate = providerIds\n ? this.registry.listMetrics().filter(m => providerIds.includes(m.id))\n : this.registry.listMetrics();\n\n const supportedMetricsToCalculate = metricsToCalculate.filter(m => {\n const provider = this.registry.getProvider(m.id);\n return provider.supportsEntity(entity);\n });\n\n const authorizedMetricsToCalculate = filterAuthorizedMetrics(\n supportedMetricsToCalculate,\n filter,\n );\n const rawResults = await this.registry.calculateMetrics(\n authorizedMetricsToCalculate.map(m => m.id),\n entity,\n );\n\n return rawResults.map(({ providerId, value, error }, index) => {\n const provider = this.registry.getProvider(providerId);\n const metric = authorizedMetricsToCalculate[index];\n\n let thresholds: ThresholdConfig | undefined;\n let evaluation: string | undefined;\n let thresholdError: string | undefined;\n try {\n thresholds = this.mergeEntityAndProviderThresholds(\n entity,\n provider,\n metric.type,\n );\n if (value === undefined) {\n thresholdError =\n 'Unable to evaluate thresholds, metric value is missing';\n } else {\n evaluation = this.thresholdEvaluator.getFirstMatchingThreshold(\n value,\n metric.type,\n thresholds,\n );\n }\n } catch (e) {\n thresholdError = stringifyError(e);\n }\n\n const isMetricCalcError = error || value === undefined;\n return {\n id: metric.id,\n status: isMetricCalcError ? 'error' : 'success',\n metadata: {\n title: metric.title,\n description: metric.description,\n type: metric.type,\n history: metric.history,\n },\n ...(isMetricCalcError && {\n error: error\n ? stringifyError(error)\n : stringifyError(new Error(`Metric value is 'undefined'`)),\n }),\n result: {\n value,\n timestamp: new Date().toISOString(),\n thresholdResult: {\n definition: thresholds,\n status: thresholdError ? 'error' : 'success',\n evaluation,\n ...(thresholdError && { error: thresholdError }),\n },\n },\n };\n });\n }\n\n private async getServiceToken(): Promise<{ token: string } | undefined> {\n if (!this.auth) {\n return undefined;\n }\n return await this.auth.getPluginRequestToken({\n onBehalfOf: await this.auth.getOwnServiceCredentials(),\n targetPluginId: 'catalog',\n });\n }\n\n /**\n * Parse threshold overrides from entity annotations.\n * Looks for annotations in the format:\n * scorecard.io/{providerId}.thresholds.rules.{thresholdName}: \"{expression}\"\n *\n * @param entity - The catalog entity\n * @param providerId - The metric provider ID (e.g., 'jira.open_issues')\n * @param metricType - The metric type\n * @returns Threshold rules from entity annotations, or empty rules if none found or invalid\n */\n private parseEntityOverrideThresholds(\n entity: Entity,\n providerId: string,\n metricType: MetricType,\n ): ThresholdRule[] {\n const annotations = entity.metadata?.annotations || {};\n const prefix = `scorecard.io/${providerId}.thresholds.rules.`;\n const overrides: ThresholdRule[] = [];\n\n for (const [annotationKey, expression] of Object.entries(annotations)) {\n if (annotationKey.startsWith(prefix) && expression) {\n const key = annotationKey.substring(prefix.length);\n const entityRule = { key, expression };\n try {\n validateThresholds({ rules: [entityRule] }, metricType);\n overrides.push(entityRule);\n } catch (e) {\n if (isError(e)) {\n throw new ThresholdConfigFormatError(\n `Invalid threshold annotation '${annotationKey}: ${expression}' in entity '${stringifyEntityRef(\n entity,\n )}': ${e.message}`,\n );\n }\n throw e;\n }\n }\n }\n\n return overrides;\n }\n\n private mergeEntityAndProviderThresholds(\n entity: Entity,\n provider: MetricProvider,\n metricType: MetricType,\n ): ThresholdConfig {\n const providerThresholds = provider.getMetricThresholds();\n const entityOverrideThresholds = this.parseEntityOverrideThresholds(\n entity,\n provider.getProviderId(),\n metricType,\n );\n\n const mergedRules = [...providerThresholds.rules];\n for (const override of entityOverrideThresholds) {\n const foundKey = mergedRules.findIndex(rule => rule.key === override.key);\n if (foundKey !== -1) {\n mergedRules[foundKey] = override;\n } else {\n throw new ThresholdConfigFormatError(\n `Unable to override ${stringifyEntityRef(\n entity,\n )} thresholds by ${JSON.stringify(\n override,\n )}, metric provider ${provider.getProviderId()} does not support key ${\n override.key\n }`,\n );\n }\n }\n\n return {\n rules: mergedRules,\n };\n }\n}\n"],"names":["NotFoundError","filterAuthorizedMetrics","stringifyError","validateThresholds","isError","ThresholdConfigFormatError","stringifyEntityRef"],"mappings":"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"}
@@ -0,0 +1,95 @@
1
+ 'use strict';
2
+
3
+ var errors = require('@backstage/errors');
4
+ var zod = require('zod');
5
+ var express = require('express');
6
+ var Router = require('express-promise-router');
7
+ var pluginPermissionCommon = require('@backstage/plugin-permission-common');
8
+ var backstagePluginScorecardCommon = require('@red-hat-developer-hub/backstage-plugin-scorecard-common');
9
+ var permissionUtils = require('../permissions/permissionUtils.cjs.js');
10
+
11
+ function _interopDefaultCompat (e) { return e && typeof e === 'object' && 'default' in e ? e : { default: e }; }
12
+
13
+ var express__default = /*#__PURE__*/_interopDefaultCompat(express);
14
+ var Router__default = /*#__PURE__*/_interopDefaultCompat(Router);
15
+
16
+ async function createRouter({
17
+ metricProvidersRegistry,
18
+ catalogMetricService,
19
+ httpAuth,
20
+ permissions
21
+ }) {
22
+ const router = Router__default.default();
23
+ router.use(express__default.default.json());
24
+ const authorizeConditional = async (request, permission) => {
25
+ const credentials = await httpAuth.credentials(request);
26
+ let decision;
27
+ if (permission.type === "resource") {
28
+ decision = (await permissions.authorizeConditional([{ permission }], {
29
+ credentials
30
+ }))[0];
31
+ } else {
32
+ decision = (await permissions.authorize([{ permission }], {
33
+ credentials
34
+ }))[0];
35
+ }
36
+ if (decision.result === pluginPermissionCommon.AuthorizeResult.DENY) {
37
+ throw new errors.NotAllowedError();
38
+ }
39
+ return {
40
+ decision,
41
+ conditions: decision.result === pluginPermissionCommon.AuthorizeResult.CONDITIONAL ? decision.conditions : void 0
42
+ };
43
+ };
44
+ router.get("/metrics", async (req, res) => {
45
+ const { conditions } = await authorizeConditional(
46
+ req,
47
+ backstagePluginScorecardCommon.scorecardMetricReadPermission
48
+ );
49
+ const metricsSchema = zod.z.object({
50
+ datasource: zod.z.string().min(1).optional()
51
+ });
52
+ const parsed = metricsSchema.safeParse(req.query);
53
+ if (!parsed.success) {
54
+ throw new errors.InputError(`Invalid query parameters: ${parsed.error.message}`);
55
+ }
56
+ const { datasource } = parsed.data;
57
+ let metrics;
58
+ if (datasource) {
59
+ metrics = metricProvidersRegistry.listMetricsByDatasource(datasource);
60
+ } else {
61
+ metrics = metricProvidersRegistry.listMetrics();
62
+ }
63
+ res.json({
64
+ metrics: permissionUtils.filterAuthorizedMetrics(metrics, conditions)
65
+ });
66
+ });
67
+ router.get("/metrics/catalog/:kind/:namespace/:name", async (req, res) => {
68
+ const { conditions } = await authorizeConditional(
69
+ req,
70
+ backstagePluginScorecardCommon.scorecardMetricReadPermission
71
+ );
72
+ const { kind, namespace, name } = req.params;
73
+ const { metricIds } = req.query;
74
+ const catalogMetricsSchema = zod.z.object({
75
+ metricIds: zod.z.string().min(1).optional()
76
+ });
77
+ const parsed = catalogMetricsSchema.safeParse(req.query);
78
+ if (!parsed.success) {
79
+ throw new errors.InputError(`Invalid query parameters: ${parsed.error.message}`);
80
+ }
81
+ const entityRef = `${kind}:${namespace}/${name}`;
82
+ await permissionUtils.checkEntityAccess(entityRef, req, permissions, httpAuth);
83
+ const metricIdArray = metricIds ? metricIds.split(",").map((id) => id.trim()) : void 0;
84
+ const results = await catalogMetricService.calculateEntityMetrics(
85
+ entityRef,
86
+ metricIdArray,
87
+ conditions
88
+ );
89
+ res.json(results);
90
+ });
91
+ return router;
92
+ }
93
+
94
+ exports.createRouter = createRouter;
95
+ //# sourceMappingURL=router.cjs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"router.cjs.js","sources":["../../src/service/router.ts"],"sourcesContent":["/*\n * Copyright Red Hat, Inc.\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 */\nimport { InputError, NotAllowedError } from '@backstage/errors';\nimport { z } from 'zod';\nimport express, { Request } from 'express';\nimport Router from 'express-promise-router';\nimport type { CatalogMetricService } from './CatalogMetricService';\nimport type { MetricProvidersRegistry } from '../providers/MetricProvidersRegistry';\nimport {\n type HttpAuthService,\n type PermissionsService,\n} from '@backstage/backend-plugin-api';\nimport {\n AuthorizeResult,\n BasicPermission,\n PolicyDecision,\n ResourcePermission,\n} from '@backstage/plugin-permission-common';\nimport { scorecardMetricReadPermission } from '@red-hat-developer-hub/backstage-plugin-scorecard-common';\nimport {\n filterAuthorizedMetrics,\n checkEntityAccess,\n} from '../permissions/permissionUtils';\n\nexport type ScorecardRouterOptions = {\n metricProvidersRegistry: MetricProvidersRegistry;\n catalogMetricService: CatalogMetricService;\n httpAuth: HttpAuthService;\n permissions: PermissionsService;\n};\n\nexport async function createRouter({\n metricProvidersRegistry,\n catalogMetricService,\n httpAuth,\n permissions,\n}: ScorecardRouterOptions): Promise<express.Router> {\n const router = Router();\n router.use(express.json());\n\n const authorizeConditional = async (\n request: Request,\n permission: ResourcePermission<'scorecard-metric'> | BasicPermission,\n ) => {\n const credentials = await httpAuth.credentials(request);\n let decision: PolicyDecision;\n\n if (permission.type === 'resource') {\n decision = (\n await permissions.authorizeConditional([{ permission }], {\n credentials,\n })\n )[0];\n } else {\n decision = (\n await permissions.authorize([{ permission }], {\n credentials,\n })\n )[0];\n }\n\n if (decision.result === AuthorizeResult.DENY) {\n throw new NotAllowedError(); // 403\n }\n\n return {\n decision,\n conditions:\n decision.result === AuthorizeResult.CONDITIONAL\n ? decision.conditions\n : undefined,\n };\n };\n\n router.get('/metrics', async (req, res) => {\n const { conditions } = await authorizeConditional(\n req,\n scorecardMetricReadPermission,\n );\n\n const metricsSchema = z.object({\n datasource: z.string().min(1).optional(),\n });\n\n const parsed = metricsSchema.safeParse(req.query);\n if (!parsed.success) {\n throw new InputError(`Invalid query parameters: ${parsed.error.message}`);\n }\n\n const { datasource } = parsed.data;\n\n let metrics;\n if (datasource) {\n metrics = metricProvidersRegistry.listMetricsByDatasource(datasource);\n } else {\n metrics = metricProvidersRegistry.listMetrics();\n }\n\n res.json({\n metrics: filterAuthorizedMetrics(metrics, conditions),\n });\n });\n\n router.get('/metrics/catalog/:kind/:namespace/:name', async (req, res) => {\n const { conditions } = await authorizeConditional(\n req,\n scorecardMetricReadPermission,\n );\n\n const { kind, namespace, name } = req.params;\n const { metricIds } = req.query;\n\n const catalogMetricsSchema = z.object({\n metricIds: z.string().min(1).optional(),\n });\n\n const parsed = catalogMetricsSchema.safeParse(req.query);\n if (!parsed.success) {\n throw new InputError(`Invalid query parameters: ${parsed.error.message}`);\n }\n\n const entityRef = `${kind}:${namespace}/${name}`;\n\n // Check if user has permission to read this specific catalog entity\n await checkEntityAccess(entityRef, req, permissions, httpAuth);\n\n const metricIdArray = metricIds\n ? (metricIds as string).split(',').map(id => id.trim())\n : undefined;\n\n const results = await catalogMetricService.calculateEntityMetrics(\n entityRef,\n metricIdArray,\n conditions,\n );\n res.json(results);\n });\n\n return router;\n}\n"],"names":["Router","express","AuthorizeResult","NotAllowedError","scorecardMetricReadPermission","z","InputError","filterAuthorizedMetrics","checkEntityAccess"],"mappings":"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"}
@@ -0,0 +1,54 @@
1
+ 'use strict';
2
+
3
+ var backstagePluginScorecardNode = require('@red-hat-developer-hub/backstage-plugin-scorecard-node');
4
+
5
+ class ThresholdEvaluator {
6
+ operations = {
7
+ ">=": (a, b) => a >= b,
8
+ ">": (a, b) => a > b,
9
+ "<=": (a, b) => a <= b,
10
+ "<": (a, b) => a < b,
11
+ "==": (a, b) => a === b,
12
+ "!=": (a, b) => a !== b,
13
+ "-": (a, [min, max]) => {
14
+ return a >= min && a <= max;
15
+ }
16
+ };
17
+ /**
18
+ * Evaluate a metric value against a threshold expression
19
+ * @param metricValue - The value to evaluate
20
+ * @param metricType - The type of metric
21
+ * @param expression - The threshold expression (e.g., ">40", "==true")
22
+ * @returns true if the metric value matches the threshold expression
23
+ */
24
+ evaluateThreshold(metricValue, metricType, expression) {
25
+ const result = backstagePluginScorecardNode.parseThresholdExpression(expression, metricType);
26
+ if (result.operator === "-") {
27
+ if (typeof metricValue !== "number") {
28
+ throw new backstagePluginScorecardNode.ThresholdConfigFormatError(
29
+ `Range expressions are only supported for number metrics, got: "${metricValue}" value for expression "${expression}"`
30
+ );
31
+ }
32
+ return this.operations["-"](metricValue, result.values);
33
+ }
34
+ const operatorFn = this.operations[result.operator];
35
+ return operatorFn(metricValue, result.value);
36
+ }
37
+ /**
38
+ * Evaluate thresholds for a metric value and return first matching
39
+ * @param metricValue - The value to evaluate
40
+ * @param thresholds - The threshold configuration
41
+ * @returns threshold key that first matches the threshold expression for metricValue or undefined
42
+ */
43
+ getFirstMatchingThreshold(metricValue, metricType, thresholds) {
44
+ for (const rule of thresholds.rules) {
45
+ if (this.evaluateThreshold(metricValue, metricType, rule.expression)) {
46
+ return rule.key;
47
+ }
48
+ }
49
+ return void 0;
50
+ }
51
+ }
52
+
53
+ exports.ThresholdEvaluator = ThresholdEvaluator;
54
+ //# sourceMappingURL=ThresholdEvaluator.cjs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ThresholdEvaluator.cjs.js","sources":["../../src/threshold/ThresholdEvaluator.ts"],"sourcesContent":["/*\n * Copyright Red Hat, Inc.\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 {\n MetricType,\n MetricValue,\n ThresholdConfig,\n} from '@red-hat-developer-hub/backstage-plugin-scorecard-common';\nimport {\n parseThresholdExpression,\n ThresholdConfigFormatError,\n} from '@red-hat-developer-hub/backstage-plugin-scorecard-node';\n\n/**\n * Service for evaluating metric values against threshold expressions\n */\nexport class ThresholdEvaluator {\n private readonly operations = {\n '>=': (a: MetricValue, b: MetricValue) => a >= b,\n '>': (a: MetricValue, b: MetricValue) => a > b,\n '<=': (a: MetricValue, b: MetricValue) => a <= b,\n '<': (a: MetricValue, b: MetricValue) => a < b,\n '==': (a: MetricValue, b: MetricValue) => a === b,\n '!=': (a: MetricValue, b: MetricValue) => a !== b,\n '-': (a: number, [min, max]: [number, number]) => {\n return a >= min && a <= max;\n },\n } as const;\n\n /**\n * Evaluate a metric value against a threshold expression\n * @param metricValue - The value to evaluate\n * @param metricType - The type of metric\n * @param expression - The threshold expression (e.g., \">40\", \"==true\")\n * @returns true if the metric value matches the threshold expression\n */\n private evaluateThreshold(\n metricValue: MetricValue,\n metricType: MetricType,\n expression: string,\n ): boolean {\n const result = parseThresholdExpression(expression, metricType);\n\n if (result.operator === '-') {\n if (typeof metricValue !== 'number') {\n throw new ThresholdConfigFormatError(\n `Range expressions are only supported for number metrics, got: \"${metricValue}\" value for expression \"${expression}\"`,\n );\n }\n return this.operations['-'](metricValue, result.values);\n }\n\n const operatorFn = this.operations[result.operator];\n return operatorFn(metricValue, result.value);\n }\n\n /**\n * Evaluate thresholds for a metric value and return first matching\n * @param metricValue - The value to evaluate\n * @param thresholds - The threshold configuration\n * @returns threshold key that first matches the threshold expression for metricValue or undefined\n */\n getFirstMatchingThreshold(\n metricValue: MetricValue,\n metricType: MetricType,\n thresholds: ThresholdConfig,\n ): string | undefined {\n for (const rule of thresholds.rules) {\n if (this.evaluateThreshold(metricValue, metricType, rule.expression)) {\n return rule.key;\n }\n }\n\n return undefined;\n }\n}\n"],"names":["parseThresholdExpression","ThresholdConfigFormatError"],"mappings":";;;;AA6BO,MAAM,kBAAmB,CAAA;AAAA,EACb,UAAa,GAAA;AAAA,IAC5B,IAAM,EAAA,CAAC,CAAgB,EAAA,CAAA,KAAmB,CAAK,IAAA,CAAA;AAAA,IAC/C,GAAK,EAAA,CAAC,CAAgB,EAAA,CAAA,KAAmB,CAAI,GAAA,CAAA;AAAA,IAC7C,IAAM,EAAA,CAAC,CAAgB,EAAA,CAAA,KAAmB,CAAK,IAAA,CAAA;AAAA,IAC/C,GAAK,EAAA,CAAC,CAAgB,EAAA,CAAA,KAAmB,CAAI,GAAA,CAAA;AAAA,IAC7C,IAAM,EAAA,CAAC,CAAgB,EAAA,CAAA,KAAmB,CAAM,KAAA,CAAA;AAAA,IAChD,IAAM,EAAA,CAAC,CAAgB,EAAA,CAAA,KAAmB,CAAM,KAAA,CAAA;AAAA,IAChD,KAAK,CAAC,CAAA,EAAW,CAAC,GAAA,EAAK,GAAG,CAAwB,KAAA;AAChD,MAAO,OAAA,CAAA,IAAK,OAAO,CAAK,IAAA,GAAA;AAAA;AAC1B,GACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASQ,iBAAA,CACN,WACA,EAAA,UAAA,EACA,UACS,EAAA;AACT,IAAM,MAAA,MAAA,GAASA,qDAAyB,CAAA,UAAA,EAAY,UAAU,CAAA;AAE9D,IAAI,IAAA,MAAA,CAAO,aAAa,GAAK,EAAA;AAC3B,MAAI,IAAA,OAAO,gBAAgB,QAAU,EAAA;AACnC,QAAA,MAAM,IAAIC,uDAAA;AAAA,UACR,CAAA,+DAAA,EAAkE,WAAW,CAAA,wBAAA,EAA2B,UAAU,CAAA,CAAA;AAAA,SACpH;AAAA;AAEF,MAAA,OAAO,KAAK,UAAW,CAAA,GAAG,CAAE,CAAA,WAAA,EAAa,OAAO,MAAM,CAAA;AAAA;AAGxD,IAAA,MAAM,UAAa,GAAA,IAAA,CAAK,UAAW,CAAA,MAAA,CAAO,QAAQ,CAAA;AAClD,IAAO,OAAA,UAAA,CAAW,WAAa,EAAA,MAAA,CAAO,KAAK,CAAA;AAAA;AAC7C;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,yBAAA,CACE,WACA,EAAA,UAAA,EACA,UACoB,EAAA;AACpB,IAAW,KAAA,MAAA,IAAA,IAAQ,WAAW,KAAO,EAAA;AACnC,MAAA,IAAI,KAAK,iBAAkB,CAAA,WAAA,EAAa,UAAY,EAAA,IAAA,CAAK,UAAU,CAAG,EAAA;AACpE,QAAA,OAAO,IAAK,CAAA,GAAA;AAAA;AACd;AAGF,IAAO,OAAA,MAAA;AAAA;AAEX;;;;"}
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "@red-hat-developer-hub/backstage-plugin-scorecard-backend",
3
+ "version": "1.0.0",
4
+ "license": "Apache-2.0",
5
+ "main": "dist/index.cjs.js",
6
+ "types": "dist/index.d.ts",
7
+ "publishConfig": {
8
+ "access": "public",
9
+ "main": "dist/index.cjs.js",
10
+ "types": "dist/index.d.ts"
11
+ },
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "https://github.com/redhat-developer/rhdh-plugins",
15
+ "directory": "workspaces/scorecard/plugins/scorecard-backend"
16
+ },
17
+ "backstage": {
18
+ "role": "backend-plugin",
19
+ "pluginId": "scorecard",
20
+ "pluginPackages": [
21
+ "@red-hat-developer-hub/backstage-plugin-scorecard",
22
+ "@red-hat-developer-hub/backstage-plugin-scorecard-backend",
23
+ "@red-hat-developer-hub/backstage-plugin-scorecard-common",
24
+ "@red-hat-developer-hub/backstage-plugin-scorecard-node"
25
+ ],
26
+ "features": {
27
+ ".": "@backstage/BackendFeature"
28
+ }
29
+ },
30
+ "configSchema": "config.d.ts",
31
+ "scripts": {
32
+ "start": "backstage-cli package start",
33
+ "build": "backstage-cli package build",
34
+ "lint": "backstage-cli package lint",
35
+ "test": "backstage-cli package test",
36
+ "clean": "backstage-cli package clean",
37
+ "prepack": "backstage-cli package prepack",
38
+ "postpack": "backstage-cli package postpack"
39
+ },
40
+ "dependencies": {
41
+ "@backstage/backend-defaults": "^0.12.0",
42
+ "@backstage/backend-plugin-api": "^1.4.2",
43
+ "@backstage/catalog-client": "^1.11.0",
44
+ "@backstage/catalog-model": "^1.7.5",
45
+ "@backstage/errors": "^1.2.7",
46
+ "@backstage/plugin-catalog-common": "^1.1.5",
47
+ "@backstage/plugin-catalog-node": "^1.18.0",
48
+ "@backstage/plugin-permission-common": "^0.9.1",
49
+ "@backstage/plugin-permission-node": "^0.10.3",
50
+ "@red-hat-developer-hub/backstage-plugin-scorecard-common": "^1.0.0",
51
+ "@red-hat-developer-hub/backstage-plugin-scorecard-node": "^1.0.0",
52
+ "express": "^4.17.1",
53
+ "express-promise-router": "^4.1.0",
54
+ "zod": "^3.22.4"
55
+ },
56
+ "devDependencies": {
57
+ "@backstage/backend-test-utils": "^1.8.0",
58
+ "@backstage/cli": "^0.34.1",
59
+ "@types/express": "^4.17.6",
60
+ "@types/supertest": "^2.0.12",
61
+ "supertest": "^6.2.4"
62
+ },
63
+ "files": [
64
+ "dist"
65
+ ],
66
+ "typesVersions": {
67
+ "*": {
68
+ "package.json": [
69
+ "package.json"
70
+ ]
71
+ }
72
+ }
73
+ }