@red-hat-developer-hub/plugin-cost-management-backend 2.0.2

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,86 @@
1
+ # @red-hat-developer-hub/plugin-cost-management-backend
2
+
3
+ ## 2.0.2
4
+
5
+ ### Patch Changes
6
+
7
+ - a889090: - Migrate from rhdh-theme to the theme within rhdh-plugins repo
8
+ - Fix package name in README and CHANGELOG
9
+ - Align pluginId in package.json to group pluginPackages
10
+ - 03c0a11: Fix #1317: updated the outdated repository reference.
11
+ - Updated dependencies [a889090]
12
+ - Updated dependencies [03c0a11]
13
+ - @red-hat-developer-hub/plugin-cost-management-common@2.0.1
14
+
15
+ ## 2.0.1
16
+
17
+ ### Patch Changes
18
+
19
+ - 59b885a: Updated dependency `@types/lodash` to `4.17.19`.
20
+ - 5681ed0: Updated dependency `@types/lodash` to `4.17.20`.
21
+
22
+ ## 2.0.0
23
+
24
+ ### Major Changes
25
+
26
+ - 02bf923: plugin for viewing and applying Red Hat Insight recommendations
27
+ - 67c18a8: sync with original repo
28
+
29
+ ### Patch Changes
30
+
31
+ - 54ca4c1: Updated dependency `@types/express` to `4.17.23`.
32
+ Updated dependency `@types/lodash` to `4.17.18`.
33
+ - Updated dependencies [02bf923]
34
+ - Updated dependencies [67c18a8]
35
+ - @red-hat-developer-hub/plugin-cost-management-common@2.0.0
36
+
37
+ ## 1.1.0
38
+
39
+ ### Minor Changes
40
+
41
+ - 613061d: bump backstage to 1.34.2 and remove @spotify/prettier-config
42
+
43
+ ## 1.0.6
44
+
45
+ ### Patch Changes
46
+
47
+ - a42b983: Removed usages and references of `@backstage/backend-common`
48
+
49
+ ## 1.0.5
50
+
51
+ ### Patch Changes
52
+
53
+ - a3c0dc2: Removed `export-dynamic` script and Janus IDP cli from the build process and npm release.
54
+
55
+ ## 1.0.4
56
+
57
+ ### Patch Changes
58
+
59
+ - 3a1aab2: Backstage version bump to v1.32.2
60
+
61
+ ## 1.0.3
62
+
63
+ ### Patch Changes
64
+
65
+ - df1288f: Backstage version bump to v1.31.2
66
+
67
+ ## 1.0.2
68
+
69
+ ### Patch Changes
70
+
71
+ - b209d3b: Backstage version bump to v1.30.2
72
+
73
+ ## 1.0.1
74
+
75
+ ### Patch Changes
76
+
77
+ - ae2ee8a: Updated dependency `@types/supertest` to `^6.0.0`.
78
+ Updated dependency `supertest` to `^7.0.0`.
79
+
80
+ ## 1.0.0
81
+
82
+ ### Major Changes
83
+
84
+ - 02dd072: Adds the cost-management plugin
85
+
86
+ This is the first iteration for this plugin. Your feedback is more than welcome!
package/README.md ADDED
@@ -0,0 +1,53 @@
1
+ # Resource Optimization back-end plugin
2
+
3
+ The cost-management backend plugin provides a secure server-side proxy for the
4
+ Red Hat Cost Management API. All communication with the upstream API happens
5
+ within the backend — SSO tokens never leave the server and RBAC filtering is
6
+ enforced before data is returned to the browser.
7
+
8
+ ## Architecture
9
+
10
+ The plugin exposes a single catch-all route at `/api/cost-management/proxy/*`.
11
+ When a request arrives the handler:
12
+
13
+ 1. **Authenticates** the caller via Backstage `httpAuth` (requires a valid user session).
14
+ 2. **Checks permissions** through the Backstage permission framework using the
15
+ `ros.*` and `cost.*` permission sets (see [docs/rbac.md](../../docs/rbac.md)).
16
+ 3. **Obtains an SSO token** internally via the OAuth2 `client_credentials` grant
17
+ using the `costManagement.clientId` / `costManagement.clientSecret` from
18
+ `app-config`.
19
+ 4. **Strips** any client-supplied RBAC-controlled query parameters (`cluster`,
20
+ `project`, `filter[exact:cluster]`, `filter[exact:project]`).
21
+ 5. **Injects** server-authorised cluster/project filters from the permission
22
+ decision.
23
+ 6. **Forwards** the request to the Red Hat Cost Management API and streams the
24
+ response back.
25
+
26
+ ### Endpoints
27
+
28
+ | Path | Auth | Description |
29
+ | ---------------------------------- | ------------- | ----------------------------------- |
30
+ | `GET /api/cost-management/health` | None | Health check |
31
+ | `ALL /api/cost-management/proxy/*` | `user-cookie` | Secure proxy to Cost Management API |
32
+
33
+ ### Configuration
34
+
35
+ ```yaml
36
+ # app-config.yaml
37
+ costManagement:
38
+ clientId: ${RHHCC_SA_CLIENT_ID}
39
+ clientSecret: ${RHHCC_SA_CLIENT_SECRET}
40
+ ```
41
+
42
+ No `proxy` block with `dangerously-allow-unauthenticated` is needed — the
43
+ backend plugin handles upstream communication directly.
44
+
45
+ ## Getting started
46
+
47
+ The plugin has been added to the example app in this workspace, meaning you'll be able to access it by running `yarn
48
+ start` from the root directory, and then navigating to http://localhost:3000/cost-management/optimizations.
49
+ The health check endpoint for this back-end is available at: http://localhost:7007/api/cost-management/health.
50
+
51
+ You can also serve the plugin in isolation by running `yarn start:dev` in the plugin directory.
52
+ This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads.
53
+ It is only meant for local development, and the setup for it can be found inside the [/dev](/dev) directory.
@@ -0,0 +1,3 @@
1
+ costManagement:
2
+ clientId: ${CM_CLIENT_ID}
3
+ clientSecret: ${CM_CLIENT_SECRET}
package/config.d.ts ADDED
@@ -0,0 +1,41 @@
1
+ /*
2
+ * Copyright Red Hat, Inc.
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ export interface Config {
18
+ costManagement: {
19
+ /**
20
+ * @default "https://sso.redhat.com"
21
+ *
22
+ * @visibility backend
23
+ */
24
+ ssoBaseUrl?: string;
25
+
26
+ /** @visibility backend */
27
+ clientId: string;
28
+
29
+ /** @visibility secret */
30
+ clientSecret: string;
31
+ };
32
+
33
+ /**
34
+ * Base URL for the Cost Management API proxy target.
35
+ *
36
+ * @default "https://console.redhat.com/api"
37
+ *
38
+ * @visibility backend
39
+ */
40
+ costManagementProxyBaseUrl?: string;
41
+ }
@@ -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.costManagementPlugin;
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
+ * costManagementPlugin backend plugin
5
+ *
6
+ * @public
7
+ */
8
+ declare const costManagementPlugin: _backstage_backend_plugin_api.BackendFeature;
9
+
10
+ export { costManagementPlugin as default };
@@ -0,0 +1,77 @@
1
+ 'use strict';
2
+
3
+ var backendPluginApi = require('@backstage/backend-plugin-api');
4
+ var router = require('./service/router.cjs.js');
5
+ var optimizationsService = require('./service/optimizationsService.cjs.js');
6
+ var costManagementService = require('./service/costManagementService.cjs.js');
7
+
8
+ const costManagementPlugin = backendPluginApi.createBackendPlugin({
9
+ pluginId: "cost-management",
10
+ register(env) {
11
+ env.registerInit({
12
+ deps: {
13
+ httpRouter: backendPluginApi.coreServices.httpRouter,
14
+ logger: backendPluginApi.coreServices.logger,
15
+ config: backendPluginApi.coreServices.rootConfig,
16
+ httpAuth: backendPluginApi.coreServices.httpAuth,
17
+ permissions: backendPluginApi.coreServices.permissions,
18
+ cache: backendPluginApi.coreServices.cache,
19
+ discovery: backendPluginApi.coreServices.discovery,
20
+ auth: backendPluginApi.coreServices.auth,
21
+ userInfo: backendPluginApi.coreServices.userInfo,
22
+ optimizationApi: optimizationsService.optimizationServiceRef,
23
+ costManagementApi: costManagementService.costManagementServiceRef
24
+ },
25
+ async init({
26
+ httpRouter,
27
+ logger,
28
+ config,
29
+ httpAuth,
30
+ permissions,
31
+ cache,
32
+ discovery,
33
+ auth,
34
+ userInfo,
35
+ optimizationApi,
36
+ costManagementApi
37
+ }) {
38
+ const router$1 = await router.createRouter({
39
+ logger,
40
+ config,
41
+ httpAuth,
42
+ permissions,
43
+ cache,
44
+ discovery,
45
+ auth,
46
+ userInfo,
47
+ optimizationApi,
48
+ costManagementApi
49
+ });
50
+ httpRouter.use(router$1);
51
+ httpRouter.addAuthPolicy({
52
+ path: "/health",
53
+ allow: "unauthenticated"
54
+ });
55
+ httpRouter.addAuthPolicy({
56
+ path: "/access",
57
+ allow: "user-cookie"
58
+ });
59
+ httpRouter.addAuthPolicy({
60
+ path: "/access/cost-management",
61
+ allow: "user-cookie"
62
+ });
63
+ httpRouter.addAuthPolicy({
64
+ path: "/proxy",
65
+ allow: "user-cookie"
66
+ });
67
+ httpRouter.addAuthPolicy({
68
+ path: "/apply-recommendation",
69
+ allow: "user-cookie"
70
+ });
71
+ }
72
+ });
73
+ }
74
+ });
75
+
76
+ exports.costManagementPlugin = costManagementPlugin;
77
+ //# 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 */\n\nimport {\n coreServices,\n createBackendPlugin,\n} from '@backstage/backend-plugin-api';\nimport { createRouter } from './service/router';\nimport { optimizationServiceRef } from './service/optimizationsService';\nimport { costManagementServiceRef } from './service/costManagementService';\n\n/**\n * costManagementPlugin backend plugin\n *\n * @public\n */\nexport const costManagementPlugin = createBackendPlugin({\n pluginId: 'cost-management',\n register(env) {\n env.registerInit({\n deps: {\n httpRouter: coreServices.httpRouter,\n logger: coreServices.logger,\n config: coreServices.rootConfig,\n httpAuth: coreServices.httpAuth,\n permissions: coreServices.permissions,\n cache: coreServices.cache,\n discovery: coreServices.discovery,\n auth: coreServices.auth,\n userInfo: coreServices.userInfo,\n optimizationApi: optimizationServiceRef,\n costManagementApi: costManagementServiceRef,\n },\n async init({\n httpRouter,\n logger,\n config,\n httpAuth,\n permissions,\n cache,\n discovery,\n auth,\n userInfo,\n optimizationApi,\n costManagementApi,\n }) {\n const router = await createRouter({\n logger,\n config,\n httpAuth,\n permissions,\n cache,\n discovery,\n auth,\n userInfo,\n optimizationApi,\n costManagementApi,\n });\n // @ts-ignore\n httpRouter.use(router);\n httpRouter.addAuthPolicy({\n path: '/health',\n allow: 'unauthenticated',\n });\n httpRouter.addAuthPolicy({\n path: '/access',\n allow: 'user-cookie',\n });\n httpRouter.addAuthPolicy({\n path: '/access/cost-management',\n allow: 'user-cookie',\n });\n httpRouter.addAuthPolicy({\n path: '/proxy',\n allow: 'user-cookie',\n });\n httpRouter.addAuthPolicy({\n path: '/apply-recommendation',\n allow: 'user-cookie',\n });\n },\n });\n },\n});\n"],"names":["createBackendPlugin","coreServices","optimizationServiceRef","costManagementServiceRef","router","createRouter"],"mappings":";;;;;;;AA6BO,MAAM,uBAAuBA,oCAAoB,CAAA;AAAA,EACtD,QAAU,EAAA,iBAAA;AAAA,EACV,SAAS,GAAK,EAAA;AACZ,IAAA,GAAA,CAAI,YAAa,CAAA;AAAA,MACf,IAAM,EAAA;AAAA,QACJ,YAAYC,6BAAa,CAAA,UAAA;AAAA,QACzB,QAAQA,6BAAa,CAAA,MAAA;AAAA,QACrB,QAAQA,6BAAa,CAAA,UAAA;AAAA,QACrB,UAAUA,6BAAa,CAAA,QAAA;AAAA,QACvB,aAAaA,6BAAa,CAAA,WAAA;AAAA,QAC1B,OAAOA,6BAAa,CAAA,KAAA;AAAA,QACpB,WAAWA,6BAAa,CAAA,SAAA;AAAA,QACxB,MAAMA,6BAAa,CAAA,IAAA;AAAA,QACnB,UAAUA,6BAAa,CAAA,QAAA;AAAA,QACvB,eAAiB,EAAAC,2CAAA;AAAA,QACjB,iBAAmB,EAAAC;AAAA,OACrB;AAAA,MACA,MAAM,IAAK,CAAA;AAAA,QACT,UAAA;AAAA,QACA,MAAA;AAAA,QACA,MAAA;AAAA,QACA,QAAA;AAAA,QACA,WAAA;AAAA,QACA,KAAA;AAAA,QACA,SAAA;AAAA,QACA,IAAA;AAAA,QACA,QAAA;AAAA,QACA,eAAA;AAAA,QACA;AAAA,OACC,EAAA;AACD,QAAM,MAAAC,QAAA,GAAS,MAAMC,mBAAa,CAAA;AAAA,UAChC,MAAA;AAAA,UACA,MAAA;AAAA,UACA,QAAA;AAAA,UACA,WAAA;AAAA,UACA,KAAA;AAAA,UACA,SAAA;AAAA,UACA,IAAA;AAAA,UACA,QAAA;AAAA,UACA,eAAA;AAAA,UACA;AAAA,SACD,CAAA;AAED,QAAA,UAAA,CAAW,IAAID,QAAM,CAAA;AACrB,QAAA,UAAA,CAAW,aAAc,CAAA;AAAA,UACvB,IAAM,EAAA,SAAA;AAAA,UACN,KAAO,EAAA;AAAA,SACR,CAAA;AACD,QAAA,UAAA,CAAW,aAAc,CAAA;AAAA,UACvB,IAAM,EAAA,SAAA;AAAA,UACN,KAAO,EAAA;AAAA,SACR,CAAA;AACD,QAAA,UAAA,CAAW,aAAc,CAAA;AAAA,UACvB,IAAM,EAAA,yBAAA;AAAA,UACN,KAAO,EAAA;AAAA,SACR,CAAA;AACD,QAAA,UAAA,CAAW,aAAc,CAAA;AAAA,UACvB,IAAM,EAAA,QAAA;AAAA,UACN,KAAO,EAAA;AAAA,SACR,CAAA;AACD,QAAA,UAAA,CAAW,aAAc,CAAA;AAAA,UACvB,IAAM,EAAA,uBAAA;AAAA,UACN,KAAO,EAAA;AAAA,SACR,CAAA;AAAA;AACH,KACD,CAAA;AAAA;AAEL,CAAC;;;;"}
@@ -0,0 +1,153 @@
1
+ 'use strict';
2
+
3
+ var checkPermissions = require('../util/checkPermissions.cjs.js');
4
+ var permissions = require('@red-hat-developer-hub/plugin-cost-management-common/permissions');
5
+ var tokenUtil = require('../util/tokenUtil.cjs.js');
6
+ var pluginPermissionCommon = require('@backstage/plugin-permission-common');
7
+ var auditLog = require('../util/auditLog.cjs.js');
8
+
9
+ const getAccess = (options) => async (_, response) => {
10
+ const { logger, permissions: permissions$1, httpAuth, cache, optimizationApi } = options;
11
+ let finalDecision = pluginPermissionCommon.AuthorizeResult.DENY;
12
+ const rosPluginDecision = await checkPermissions.authorize(
13
+ _,
14
+ permissions.rosPluginPermissions,
15
+ permissions$1,
16
+ httpAuth
17
+ );
18
+ logger.info(`Checking decision:`, rosPluginDecision);
19
+ if (rosPluginDecision.result === pluginPermissionCommon.AuthorizeResult.ALLOW) {
20
+ finalDecision = pluginPermissionCommon.AuthorizeResult.ALLOW;
21
+ const actor2 = await auditLog.resolveActor(_, options);
22
+ auditLog.emitAuditLog(options, {
23
+ actor: actor2,
24
+ action: "access_check",
25
+ resource: "/access",
26
+ decision: "ALLOW"
27
+ });
28
+ const body2 = {
29
+ decision: finalDecision,
30
+ authorizeClusterIds: []
31
+ };
32
+ return response.json(body2);
33
+ }
34
+ const ALL_CLUSTERS_MAP_CACHE_KEY = "all_clusters_map";
35
+ const ALL_PROJECTS_CACHE_KEY = "all_projects";
36
+ let clusterDataMap = {};
37
+ let allProjects = [];
38
+ const clusterMapDataFromCache = await cache.get(
39
+ ALL_CLUSTERS_MAP_CACHE_KEY
40
+ );
41
+ const projectDataFromCache = await cache.get(ALL_PROJECTS_CACHE_KEY);
42
+ if (clusterMapDataFromCache && projectDataFromCache) {
43
+ clusterDataMap = clusterMapDataFromCache;
44
+ allProjects = projectDataFromCache;
45
+ } else {
46
+ try {
47
+ const token = await tokenUtil.getTokenFromApi(options);
48
+ const optimizationResponse = await optimizationApi.getRecommendationList(
49
+ {
50
+ query: {
51
+ limit: -1,
52
+ orderHow: "desc",
53
+ orderBy: "last_reported"
54
+ }
55
+ },
56
+ { token }
57
+ );
58
+ const recommendationList = await optimizationResponse.json();
59
+ if (recommendationList.errors) {
60
+ logger.error(
61
+ "API returned errors:",
62
+ recommendationList.errors
63
+ );
64
+ return response.status(500).json({
65
+ decision: pluginPermissionCommon.AuthorizeResult.DENY,
66
+ error: "API returned errors",
67
+ authorizeClusterIds: [],
68
+ authorizeProjects: []
69
+ });
70
+ }
71
+ if (recommendationList.data) {
72
+ recommendationList.data.map((recommendation) => {
73
+ if (recommendation.clusterAlias && recommendation.clusterUuid)
74
+ clusterDataMap[recommendation.clusterAlias] = recommendation.clusterUuid;
75
+ });
76
+ allProjects = [
77
+ ...new Set(
78
+ recommendationList.data.map(
79
+ (recommendation) => recommendation.project
80
+ )
81
+ )
82
+ ].filter((project) => project !== void 0);
83
+ await cache.set(ALL_CLUSTERS_MAP_CACHE_KEY, clusterDataMap, {
84
+ ttl: 15 * 60 * 1e3
85
+ });
86
+ await cache.set(ALL_PROJECTS_CACHE_KEY, allProjects, {
87
+ ttl: 15 * 60 * 1e3
88
+ });
89
+ }
90
+ } catch (error) {
91
+ logger.error("Error fetching recommendations", error);
92
+ return response.status(500).json({
93
+ decision: pluginPermissionCommon.AuthorizeResult.DENY,
94
+ error: "Failed to fetch cluster data",
95
+ authorizeClusterIds: [],
96
+ authorizeProjects: []
97
+ });
98
+ }
99
+ }
100
+ logger.info(
101
+ `Checking permissions for ${Object.keys(clusterDataMap).length} clusters and ${allProjects.length} projects`
102
+ );
103
+ logger.info(`Cluster names: ${Object.keys(clusterDataMap).join(", ")}`);
104
+ logger.info(`Projects: ${allProjects.join(", ")}`);
105
+ const { authorizedClusterIds, authorizedClusterProjects } = await checkPermissions.filterAuthorizedClustersAndProjects(
106
+ _,
107
+ permissions$1,
108
+ httpAuth,
109
+ clusterDataMap,
110
+ allProjects
111
+ );
112
+ logger.info(
113
+ `Authorization results: ${authorizedClusterIds.length} cluster IDs, ${authorizedClusterProjects.length} cluster-project combinations`
114
+ );
115
+ logger.info(`Authorized cluster IDs: ${authorizedClusterIds.join(", ")}`);
116
+ logger.info(
117
+ `Authorized cluster-projects: ${authorizedClusterProjects.map((cp) => `${cp.cluster}.${cp.project}`).join(", ")}`
118
+ );
119
+ const finalAuthorizedClusterIds = [
120
+ .../* @__PURE__ */ new Set([
121
+ ...authorizedClusterIds,
122
+ ...authorizedClusterProjects.map((result) => result.cluster)
123
+ ])
124
+ ];
125
+ const authorizeProjects = authorizedClusterProjects.map(
126
+ (result) => result.project
127
+ );
128
+ if (finalAuthorizedClusterIds.length > 0) {
129
+ finalDecision = pluginPermissionCommon.AuthorizeResult.ALLOW;
130
+ } else {
131
+ finalDecision = pluginPermissionCommon.AuthorizeResult.DENY;
132
+ }
133
+ const actor = await auditLog.resolveActor(_, options);
134
+ auditLog.emitAuditLog(options, {
135
+ actor,
136
+ action: "access_check",
137
+ resource: "/access",
138
+ decision: finalDecision === pluginPermissionCommon.AuthorizeResult.ALLOW ? "ALLOW" : "DENY",
139
+ filters: {
140
+ clusters: finalAuthorizedClusterIds,
141
+ projects: authorizeProjects
142
+ }
143
+ });
144
+ const body = {
145
+ decision: finalDecision,
146
+ authorizeClusterIds: finalAuthorizedClusterIds,
147
+ authorizeProjects
148
+ };
149
+ return response.json(body);
150
+ };
151
+
152
+ exports.getAccess = getAccess;
153
+ //# sourceMappingURL=access.cjs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"access.cjs.js","sources":["../../src/routes/access.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 type { RequestHandler } from 'express';\nimport type { RouterOptions } from '../models/RouterOptions';\nimport {\n authorize,\n filterAuthorizedClustersAndProjects,\n} from '../util/checkPermissions';\nimport { rosPluginPermissions } from '@red-hat-developer-hub/plugin-cost-management-common/permissions';\nimport { getTokenFromApi } from '../util/tokenUtil';\nimport { AuthorizeResult } from '@backstage/plugin-permission-common';\nimport { resolveActor, emitAuditLog } from '../util/auditLog';\n\nexport const getAccess: (options: RouterOptions) => RequestHandler =\n options => async (_, response) => {\n const { logger, permissions, httpAuth, cache, optimizationApi } = options;\n let finalDecision = AuthorizeResult.DENY;\n\n // Check for ros.plugin permisssion\n // if user has ros.plugin permission, allow access to all the data\n const rosPluginDecision = await authorize(\n _,\n rosPluginPermissions,\n permissions,\n httpAuth,\n );\n\n logger.info(`Checking decision:`, rosPluginDecision);\n\n if (rosPluginDecision.result === AuthorizeResult.ALLOW) {\n finalDecision = AuthorizeResult.ALLOW;\n\n const actor = await resolveActor(_, options);\n emitAuditLog(options, {\n actor,\n action: 'access_check',\n resource: '/access',\n decision: 'ALLOW',\n });\n\n const body = {\n decision: finalDecision,\n authorizeClusterIds: [],\n };\n return response.json(body);\n }\n\n // RBAC Filtering logic for Cluster & Project\n const ALL_CLUSTERS_MAP_CACHE_KEY = 'all_clusters_map';\n const ALL_PROJECTS_CACHE_KEY = 'all_projects';\n\n let clusterDataMap: Record<string, string> = {};\n let allProjects: string[] = [];\n\n // Check the cluster & project data in the cache first\n const clusterMapDataFromCache = (await cache.get(\n ALL_CLUSTERS_MAP_CACHE_KEY,\n )) as Record<string, string> | undefined;\n const projectDataFromCache = (await cache.get(ALL_PROJECTS_CACHE_KEY)) as\n | string[]\n | undefined;\n\n if (clusterMapDataFromCache && projectDataFromCache) {\n clusterDataMap = clusterMapDataFromCache;\n allProjects = projectDataFromCache;\n } else {\n try {\n // token\n const token = await getTokenFromApi(options);\n\n // hit /recommendation API endpoint\n const optimizationResponse =\n await optimizationApi.getRecommendationList(\n {\n query: {\n limit: -1,\n orderHow: 'desc',\n orderBy: 'last_reported',\n },\n },\n { token },\n );\n\n // OptimizationsClient already transforms to camelCase when token is provided\n const recommendationList = await optimizationResponse.json();\n\n // Check if response contains errors\n if ((recommendationList as any).errors) {\n logger.error(\n 'API returned errors:',\n (recommendationList as any).errors,\n );\n return response.status(500).json({\n decision: AuthorizeResult.DENY,\n error: 'API returned errors',\n authorizeClusterIds: [],\n authorizeProjects: [],\n });\n }\n\n // retrive cluster data from the API result\n if (recommendationList.data) {\n recommendationList.data.map(recommendation => {\n if (recommendation.clusterAlias && recommendation.clusterUuid)\n clusterDataMap[recommendation.clusterAlias] =\n recommendation.clusterUuid;\n });\n\n allProjects = [\n ...new Set(\n recommendationList.data.map(\n recommendation => recommendation.project,\n ),\n ),\n ].filter(project => project !== undefined) as string[];\n\n // store it in Cache\n await cache.set(ALL_CLUSTERS_MAP_CACHE_KEY, clusterDataMap, {\n ttl: 15 * 60 * 1000,\n });\n await cache.set(ALL_PROJECTS_CACHE_KEY, allProjects, {\n ttl: 15 * 60 * 1000,\n });\n }\n } catch (error) {\n logger.error('Error fetching recommendations', error);\n\n // Return unauthorized response on any error\n return response.status(500).json({\n decision: AuthorizeResult.DENY,\n error: 'Failed to fetch cluster data',\n authorizeClusterIds: [],\n authorizeProjects: [],\n });\n }\n }\n\n // RBAC Filtering: Single batch call for both cluster and cluster-project permissions\n logger.info(\n `Checking permissions for ${\n Object.keys(clusterDataMap).length\n } clusters and ${allProjects.length} projects`,\n );\n logger.info(`Cluster names: ${Object.keys(clusterDataMap).join(', ')}`);\n logger.info(`Projects: ${allProjects.join(', ')}`);\n\n const { authorizedClusterIds, authorizedClusterProjects } =\n await filterAuthorizedClustersAndProjects(\n _,\n permissions,\n httpAuth,\n clusterDataMap,\n allProjects,\n );\n\n logger.info(\n `Authorization results: ${authorizedClusterIds.length} cluster IDs, ${authorizedClusterProjects.length} cluster-project combinations`,\n );\n logger.info(`Authorized cluster IDs: ${authorizedClusterIds.join(', ')}`);\n logger.info(\n `Authorized cluster-projects: ${authorizedClusterProjects\n .map(cp => `${cp.cluster}.${cp.project}`)\n .join(', ')}`,\n );\n\n // Combine cluster IDs from both cluster-level and project-level permissions\n const finalAuthorizedClusterIds = [\n ...new Set([\n ...authorizedClusterIds,\n ...authorizedClusterProjects.map(result => result.cluster),\n ]),\n ];\n\n const authorizeProjects = authorizedClusterProjects.map(\n result => result.project,\n );\n\n if (finalAuthorizedClusterIds.length > 0) {\n finalDecision = AuthorizeResult.ALLOW;\n } else {\n finalDecision = AuthorizeResult.DENY;\n }\n\n const actor = await resolveActor(_, options);\n emitAuditLog(options, {\n actor,\n action: 'access_check',\n resource: '/access',\n decision: finalDecision === AuthorizeResult.ALLOW ? 'ALLOW' : 'DENY',\n filters: {\n clusters: finalAuthorizedClusterIds,\n projects: authorizeProjects,\n },\n });\n\n const body = {\n decision: finalDecision,\n authorizeClusterIds: finalAuthorizedClusterIds,\n authorizeProjects,\n };\n\n return response.json(body);\n };\n"],"names":["permissions","AuthorizeResult","authorize","rosPluginPermissions","actor","resolveActor","emitAuditLog","body","getTokenFromApi","filterAuthorizedClustersAndProjects"],"mappings":"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"}
@@ -0,0 +1,150 @@
1
+ 'use strict';
2
+
3
+ var checkPermissions = require('../util/checkPermissions.cjs.js');
4
+ var permissions = require('@red-hat-developer-hub/plugin-cost-management-common/permissions');
5
+ var pluginPermissionCommon = require('@backstage/plugin-permission-common');
6
+ var auditLog = require('../util/auditLog.cjs.js');
7
+
8
+ const ALLOWED_RESOURCE_TYPES = /* @__PURE__ */ new Set([
9
+ "deployment",
10
+ "replicaset",
11
+ "daemonset",
12
+ "statefulset",
13
+ "deploymentconfig",
14
+ "replicationcontroller"
15
+ ]);
16
+ function validateBody(body) {
17
+ const b = body;
18
+ if (!b?.workflowId || typeof b.workflowId !== "string") {
19
+ return { valid: false, error: "Missing or invalid workflowId" };
20
+ }
21
+ const input = b.inputData;
22
+ if (!input || typeof input !== "object") {
23
+ return { valid: false, error: "Missing inputData" };
24
+ }
25
+ if (!input.resourceType || !ALLOWED_RESOURCE_TYPES.has(input.resourceType)) {
26
+ return {
27
+ valid: false,
28
+ error: `Invalid resourceType: ${input.resourceType ?? "undefined"}. Allowed: ${[...ALLOWED_RESOURCE_TYPES].join(", ")}`
29
+ };
30
+ }
31
+ for (const field of [
32
+ "clusterName",
33
+ "resourceNamespace",
34
+ "resourceName",
35
+ "containerName"
36
+ ]) {
37
+ if (!input[field] || typeof input[field] !== "string") {
38
+ return { valid: false, error: `Missing or invalid ${field}` };
39
+ }
40
+ }
41
+ if (!input.containerResources || typeof input.containerResources !== "object") {
42
+ return { valid: false, error: "Missing containerResources" };
43
+ }
44
+ return { valid: true, data: b };
45
+ }
46
+ const applyRecommendation = (options) => async (req, res) => {
47
+ const { logger, httpAuth, permissions: permissions$1, discovery, auth } = options;
48
+ const validation = validateBody(req.body);
49
+ if (!validation.valid) {
50
+ return res.status(400).json({ error: validation.error });
51
+ }
52
+ const { workflowId, inputData } = validation.data;
53
+ const actor = await auditLog.resolveActor(req, options);
54
+ const decision = await checkPermissions.authorize(
55
+ req,
56
+ permissions.rosApplyPermissions,
57
+ permissions$1,
58
+ httpAuth
59
+ );
60
+ if (decision.result !== pluginPermissionCommon.AuthorizeResult.ALLOW) {
61
+ auditLog.emitAuditLog(options, {
62
+ actor,
63
+ action: "apply_recommendation",
64
+ resource: `/apply-recommendation/${workflowId}`,
65
+ decision: "DENY",
66
+ meta: {
67
+ workflowId,
68
+ cluster: inputData.clusterName,
69
+ namespace: inputData.resourceNamespace,
70
+ workload: inputData.resourceName,
71
+ resourceType: inputData.resourceType
72
+ }
73
+ });
74
+ return res.status(403).json({ error: "Access denied: ros.apply permission required" });
75
+ }
76
+ try {
77
+ const orchestratorBase = await discovery.getBaseUrl("orchestrator");
78
+ const { token } = await auth.getPluginRequestToken({
79
+ onBehalfOf: await httpAuth.credentials(req),
80
+ targetPluginId: "orchestrator"
81
+ });
82
+ const executeUrl = `${orchestratorBase}/v2/workflows/${encodeURIComponent(
83
+ workflowId
84
+ )}/execute`;
85
+ const upstreamResponse = await fetch(executeUrl, {
86
+ method: "POST",
87
+ headers: {
88
+ "Content-Type": "application/json",
89
+ Authorization: `Bearer ${token}`
90
+ },
91
+ body: JSON.stringify({
92
+ inputData: {
93
+ clusterName: inputData.clusterName,
94
+ resourceType: inputData.resourceType,
95
+ resourceNamespace: inputData.resourceNamespace,
96
+ resourceName: inputData.resourceName,
97
+ containerName: inputData.containerName,
98
+ containerResources: inputData.containerResources
99
+ }
100
+ })
101
+ });
102
+ const contentType = upstreamResponse.headers.get("content-type") || "";
103
+ let payload;
104
+ try {
105
+ payload = contentType.includes("application/json") ? await upstreamResponse.json() : { message: await upstreamResponse.text() };
106
+ } catch {
107
+ payload = { error: "Upstream returned unparseable response" };
108
+ }
109
+ if (!upstreamResponse.ok) {
110
+ auditLog.emitAuditLog(options, {
111
+ actor,
112
+ action: "apply_recommendation",
113
+ resource: `/apply-recommendation/${workflowId}`,
114
+ decision: "ALLOW",
115
+ meta: {
116
+ workflowId,
117
+ cluster: inputData.clusterName,
118
+ namespace: inputData.resourceNamespace,
119
+ workload: inputData.resourceName,
120
+ resourceType: inputData.resourceType,
121
+ upstreamStatus: upstreamResponse.status,
122
+ outcome: "upstream_error"
123
+ }
124
+ });
125
+ return res.status(upstreamResponse.status).json(payload);
126
+ }
127
+ auditLog.emitAuditLog(options, {
128
+ actor,
129
+ action: "apply_recommendation",
130
+ resource: `/apply-recommendation/${workflowId}`,
131
+ decision: "ALLOW",
132
+ meta: {
133
+ workflowId,
134
+ instanceId: payload.id,
135
+ cluster: inputData.clusterName,
136
+ namespace: inputData.resourceNamespace,
137
+ workload: inputData.resourceName,
138
+ resourceType: inputData.resourceType,
139
+ outcome: "success"
140
+ }
141
+ });
142
+ return res.status(200).json(payload);
143
+ } catch (error) {
144
+ logger.error("Apply recommendation proxy error", error);
145
+ return res.status(500).json({ error: "Internal error executing workflow" });
146
+ }
147
+ };
148
+
149
+ exports.applyRecommendation = applyRecommendation;
150
+ //# sourceMappingURL=applyRecommendation.cjs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"applyRecommendation.cjs.js","sources":["../../src/routes/applyRecommendation.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 type { RequestHandler } from 'express';\nimport type { RouterOptions } from '../models/RouterOptions';\nimport { authorize } from '../util/checkPermissions';\nimport { rosApplyPermissions } from '@red-hat-developer-hub/plugin-cost-management-common/permissions';\nimport { AuthorizeResult } from '@backstage/plugin-permission-common';\nimport { resolveActor, emitAuditLog } from '../util/auditLog';\n\nconst ALLOWED_RESOURCE_TYPES = new Set([\n 'deployment',\n 'replicaset',\n 'daemonset',\n 'statefulset',\n 'deploymentconfig',\n 'replicationcontroller',\n]);\n\ninterface ApplyRecommendationBody {\n workflowId: string;\n inputData: {\n clusterName: string;\n resourceType: string;\n resourceNamespace: string;\n resourceName: string;\n containerName: string;\n containerResources: {\n limits?: { cpu?: number; memory?: number };\n requests?: { cpu?: number; memory?: number };\n };\n };\n}\n\nfunction validateBody(\n body: unknown,\n):\n | { valid: true; data: ApplyRecommendationBody }\n | { valid: false; error: string } {\n const b = body as ApplyRecommendationBody;\n\n if (!b?.workflowId || typeof b.workflowId !== 'string') {\n return { valid: false, error: 'Missing or invalid workflowId' };\n }\n\n const input = b.inputData;\n if (!input || typeof input !== 'object') {\n return { valid: false, error: 'Missing inputData' };\n }\n\n if (!input.resourceType || !ALLOWED_RESOURCE_TYPES.has(input.resourceType)) {\n return {\n valid: false,\n error: `Invalid resourceType: ${\n input.resourceType ?? 'undefined'\n }. Allowed: ${[...ALLOWED_RESOURCE_TYPES].join(', ')}`,\n };\n }\n\n for (const field of [\n 'clusterName',\n 'resourceNamespace',\n 'resourceName',\n 'containerName',\n ] as const) {\n if (!input[field] || typeof input[field] !== 'string') {\n return { valid: false, error: `Missing or invalid ${field}` };\n }\n }\n\n if (\n !input.containerResources ||\n typeof input.containerResources !== 'object'\n ) {\n return { valid: false, error: 'Missing containerResources' };\n }\n\n return { valid: true, data: b };\n}\n\n/**\n * Backend endpoint that validates inputs, checks ros.apply permission,\n * and forwards the workflow execution to the Orchestrator plugin.\n */\nexport const applyRecommendation: (options: RouterOptions) => RequestHandler =\n options => async (req, res) => {\n const { logger, httpAuth, permissions, discovery, auth } = options;\n\n const validation = validateBody(req.body);\n if (!validation.valid) {\n return res.status(400).json({ error: validation.error });\n }\n const { workflowId, inputData } = validation.data;\n\n const actor = await resolveActor(req, options);\n\n const decision = await authorize(\n req,\n rosApplyPermissions,\n permissions,\n httpAuth,\n );\n if (decision.result !== AuthorizeResult.ALLOW) {\n emitAuditLog(options, {\n actor,\n action: 'apply_recommendation',\n resource: `/apply-recommendation/${workflowId}`,\n decision: 'DENY',\n meta: {\n workflowId,\n cluster: inputData.clusterName,\n namespace: inputData.resourceNamespace,\n workload: inputData.resourceName,\n resourceType: inputData.resourceType,\n },\n });\n return res\n .status(403)\n .json({ error: 'Access denied: ros.apply permission required' });\n }\n\n try {\n const orchestratorBase = await discovery.getBaseUrl('orchestrator');\n const { token } = await auth.getPluginRequestToken({\n onBehalfOf: await httpAuth.credentials(req),\n targetPluginId: 'orchestrator',\n });\n\n const executeUrl = `${orchestratorBase}/v2/workflows/${encodeURIComponent(\n workflowId,\n )}/execute`;\n\n const upstreamResponse = await fetch(executeUrl, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${token}`,\n },\n body: JSON.stringify({\n inputData: {\n clusterName: inputData.clusterName,\n resourceType: inputData.resourceType,\n resourceNamespace: inputData.resourceNamespace,\n resourceName: inputData.resourceName,\n containerName: inputData.containerName,\n containerResources: inputData.containerResources,\n },\n }),\n });\n\n const contentType = upstreamResponse.headers.get('content-type') || '';\n let payload: unknown;\n try {\n payload = contentType.includes('application/json')\n ? await upstreamResponse.json()\n : { message: await upstreamResponse.text() };\n } catch {\n payload = { error: 'Upstream returned unparseable response' };\n }\n\n if (!upstreamResponse.ok) {\n emitAuditLog(options, {\n actor,\n action: 'apply_recommendation',\n resource: `/apply-recommendation/${workflowId}`,\n decision: 'ALLOW',\n meta: {\n workflowId,\n cluster: inputData.clusterName,\n namespace: inputData.resourceNamespace,\n workload: inputData.resourceName,\n resourceType: inputData.resourceType,\n upstreamStatus: upstreamResponse.status,\n outcome: 'upstream_error',\n },\n });\n return res.status(upstreamResponse.status).json(payload);\n }\n\n emitAuditLog(options, {\n actor,\n action: 'apply_recommendation',\n resource: `/apply-recommendation/${workflowId}`,\n decision: 'ALLOW',\n meta: {\n workflowId,\n instanceId: (payload as { id?: string }).id,\n cluster: inputData.clusterName,\n namespace: inputData.resourceNamespace,\n workload: inputData.resourceName,\n resourceType: inputData.resourceType,\n outcome: 'success',\n },\n });\n\n return res.status(200).json(payload);\n } catch (error) {\n logger.error('Apply recommendation proxy error', error);\n return res\n .status(500)\n .json({ error: 'Internal error executing workflow' });\n }\n };\n"],"names":["permissions","resolveActor","authorize","rosApplyPermissions","AuthorizeResult","emitAuditLog"],"mappings":"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"}