From 306e20cf19e659d05c1433255d6b17ab5094704a Mon Sep 17 00:00:00 2001 From: gharden Date: Thu, 26 Mar 2026 11:28:33 -0400 Subject: [PATCH 1/8] fix(cost-management): move data fetching server-side to eliminate token exposure and RBAC bypass Addresses security findings from the RHDH Cost Plugin threat model: - Removed /token endpoint that exposed SSO credentials to the browser - Added secure backend proxy (/api/cost-management/proxy/*) with server-side RBAC enforcement and internal SSO token management - Removed dangerously-allow-unauthenticated proxy configuration - Updated frontend clients to route through the secure backend proxy FLPATH-3487 Made-with: Cursor --- .../.changeset/secure-proxy-server-side.md | 13 + .../app-config.dynamic.yaml | 6 - .../cost-management-backend/src/plugin.ts | 6 +- .../src/routes/secureProxy.ts | 377 ++++++++++++++++++ .../src/service/router.ts | 5 +- .../report-clients.api.md | 2 +- .../cost-management-common/report.api.md | 2 +- .../CostManagementSlimClient.ts | 237 ++--------- .../optimizations/OptimizationsClient.test.ts | 30 +- .../optimizations/OptimizationsClient.ts | 167 ++------ 10 files changed, 483 insertions(+), 362 deletions(-) create mode 100644 workspaces/cost-management/.changeset/secure-proxy-server-side.md create mode 100644 workspaces/cost-management/plugins/cost-management-backend/src/routes/secureProxy.ts diff --git a/workspaces/cost-management/.changeset/secure-proxy-server-side.md b/workspaces/cost-management/.changeset/secure-proxy-server-side.md new file mode 100644 index 0000000000..c37abb314b --- /dev/null +++ b/workspaces/cost-management/.changeset/secure-proxy-server-side.md @@ -0,0 +1,13 @@ +--- +'@red-hat-developer-hub/plugin-cost-management-backend': minor +'@red-hat-developer-hub/plugin-cost-management-common': minor +'@red-hat-developer-hub/plugin-cost-management': minor +--- + +Move Cost Management data fetching server-side to eliminate token exposure and RBAC bypass + +- Added secure backend proxy (`/api/cost-management/proxy/*`) that authenticates requests via Backstage httpAuth, checks RBAC permissions, retrieves SSO tokens internally, and injects server-side cluster/project filters before forwarding to the Cost Management API +- Removed `/token` endpoint that exposed SSO service account credentials to the browser +- Removed `dangerously-allow-unauthenticated` proxy configuration from `app-config.dynamic.yaml` +- Updated `OptimizationsClient` and `CostManagementSlimClient` to route through the new secure backend proxy instead of the old Backstage proxy +- Eliminated client-side RBAC filter injection that could be bypassed by calling the proxy directly diff --git a/workspaces/cost-management/plugins/cost-management-backend/app-config.dynamic.yaml b/workspaces/cost-management/plugins/cost-management-backend/app-config.dynamic.yaml index 32e7692697..bb3e2f3139 100644 --- a/workspaces/cost-management/plugins/cost-management-backend/app-config.dynamic.yaml +++ b/workspaces/cost-management/plugins/cost-management-backend/app-config.dynamic.yaml @@ -1,9 +1,3 @@ -proxy: - endpoints: - '/cost-management/v1': - target: https://console.redhat.com/api/cost-management/v1 - allowedHeaders: ['Authorization'] - credentials: dangerously-allow-unauthenticated costManagement: clientId: ${CM_CLIENT_ID} clientSecret: ${CM_CLIENT_SECRET} diff --git a/workspaces/cost-management/plugins/cost-management-backend/src/plugin.ts b/workspaces/cost-management/plugins/cost-management-backend/src/plugin.ts index 3fe2b79b9e..a59f9cf7b9 100644 --- a/workspaces/cost-management/plugins/cost-management-backend/src/plugin.ts +++ b/workspaces/cost-management/plugins/cost-management-backend/src/plugin.ts @@ -67,15 +67,15 @@ export const costManagementPlugin = createBackendPlugin({ allow: 'unauthenticated', }); httpRouter.addAuthPolicy({ - path: '/token', + path: '/access', allow: 'user-cookie', }); httpRouter.addAuthPolicy({ - path: '/access', + path: '/access/cost-management', allow: 'user-cookie', }); httpRouter.addAuthPolicy({ - path: '/access/cost-management', + path: '/proxy', allow: 'user-cookie', }); }, diff --git a/workspaces/cost-management/plugins/cost-management-backend/src/routes/secureProxy.ts b/workspaces/cost-management/plugins/cost-management-backend/src/routes/secureProxy.ts new file mode 100644 index 0000000000..f16cb869fb --- /dev/null +++ b/workspaces/cost-management/plugins/cost-management-backend/src/routes/secureProxy.ts @@ -0,0 +1,377 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { RequestHandler, Request } from 'express'; +import type { RouterOptions } from '../models/RouterOptions'; +import { + authorize, + filterAuthorizedClustersAndProjects, +} from '../util/checkPermissions'; +import { + rosPluginPermissions, + costPluginPermissions, +} from '@red-hat-developer-hub/plugin-cost-management-common/permissions'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; +import { getTokenFromApi } from '../util/tokenUtil'; +import { DEFAULT_COST_MANAGEMENT_PROXY_BASE_URL } from '../util/constant'; + +const CACHE_TTL = 15 * 60 * 1000; + +interface AccessResult { + decision: string; + clusterFilters: string[]; + projectFilters: string[]; + filterStyle: 'ros' | 'cost'; +} + +/** + * Determines the RBAC scope for a given proxy path and resolves + * the authorized cluster/project filters server-side. + */ +async function resolveAccess( + req: Request, + proxyPath: string, + options: RouterOptions, +): Promise { + const isOptimizations = proxyPath.startsWith('recommendations/'); + + if (isOptimizations) { + return resolveOptimizationsAccess(req, options); + } + return resolveCostManagementAccess(req, options); +} + +async function resolveOptimizationsAccess( + req: Request, + options: RouterOptions, +): Promise { + const { permissions, httpAuth, cache, optimizationApi } = options; + + const pluginDecision = await authorize( + req, + rosPluginPermissions, + permissions, + httpAuth, + ); + if (pluginDecision.result === AuthorizeResult.ALLOW) { + return { + decision: 'ALLOW', + clusterFilters: [], + projectFilters: [], + filterStyle: 'ros', + }; + } + + const ALL_CLUSTERS_MAP_CACHE_KEY = 'all_clusters_map'; + const ALL_PROJECTS_CACHE_KEY = 'all_projects'; + + let clusterDataMap: Record = {}; + let allProjects: string[] = []; + + const clusterMapDataFromCache = (await cache.get( + ALL_CLUSTERS_MAP_CACHE_KEY, + )) as Record | undefined; + const projectDataFromCache = (await cache.get(ALL_PROJECTS_CACHE_KEY)) as + | string[] + | undefined; + + if (clusterMapDataFromCache && projectDataFromCache) { + clusterDataMap = clusterMapDataFromCache; + allProjects = projectDataFromCache; + } else { + const token = await getTokenFromApi(options); + const optimizationResponse = await optimizationApi.getRecommendationList( + { query: { limit: -1, orderHow: 'desc', orderBy: 'last_reported' } }, + { token }, + ); + const recommendationList = await optimizationResponse.json(); + + if ((recommendationList as any).errors) { + return { + decision: 'DENY', + clusterFilters: [], + projectFilters: [], + filterStyle: 'ros', + }; + } + + if (recommendationList.data) { + recommendationList.data.forEach(recommendation => { + if (recommendation.clusterAlias && recommendation.clusterUuid) { + clusterDataMap[recommendation.clusterAlias] = + recommendation.clusterUuid; + } + }); + allProjects = [ + ...new Set(recommendationList.data.map(r => r.project)), + ].filter(p => p !== undefined) as string[]; + + await cache.set(ALL_CLUSTERS_MAP_CACHE_KEY, clusterDataMap, { + ttl: CACHE_TTL, + }); + await cache.set(ALL_PROJECTS_CACHE_KEY, allProjects, { ttl: CACHE_TTL }); + } + } + + const { authorizedClusterIds, authorizedClusterProjects } = + await filterAuthorizedClustersAndProjects( + req, + permissions, + httpAuth, + clusterDataMap, + allProjects, + ); + + const finalClusterIds = [ + ...new Set([ + ...authorizedClusterIds, + ...authorizedClusterProjects.map(r => r.cluster), + ]), + ]; + const finalProjects = authorizedClusterProjects.map(r => r.project); + + if (finalClusterIds.length === 0) { + return { + decision: 'DENY', + clusterFilters: [], + projectFilters: [], + filterStyle: 'ros', + }; + } + + return { + decision: 'ALLOW', + clusterFilters: finalClusterIds, + projectFilters: finalProjects, + filterStyle: 'ros', + }; +} + +async function resolveCostManagementAccess( + req: Request, + options: RouterOptions, +): Promise { + const { permissions, httpAuth, cache, costManagementApi } = options; + + const pluginDecision = await authorize( + req, + costPluginPermissions, + permissions, + httpAuth, + ); + if (pluginDecision.result === AuthorizeResult.ALLOW) { + return { + decision: 'ALLOW', + clusterFilters: [], + projectFilters: [], + filterStyle: 'cost', + }; + } + + const COST_CLUSTERS_CACHE_KEY = 'cost_clusters'; + const COST_PROJECTS_CACHE_KEY = 'cost_projects'; + + let clusterDataMap: Record = {}; + let allProjects: string[] = []; + + const clustersFromCache = (await cache.get(COST_CLUSTERS_CACHE_KEY)) as + | Record + | undefined; + const projectsFromCache = (await cache.get(COST_PROJECTS_CACHE_KEY)) as + | string[] + | undefined; + + if (clustersFromCache && projectsFromCache) { + clusterDataMap = clustersFromCache; + allProjects = projectsFromCache; + } else { + const token = await getTokenFromApi(options); + const [clustersResponse, projectsResponse] = await Promise.all([ + costManagementApi.searchOpenShiftClusters('', { token, limit: 1000 }), + costManagementApi.searchOpenShiftProjects('', { token, limit: 1000 }), + ]); + + const clustersData = await clustersResponse.json(); + const projectsData = await projectsResponse.json(); + + clustersData.data?.forEach( + (cluster: { value: string; cluster_alias: string }) => { + if (cluster.cluster_alias && cluster.value) { + clusterDataMap[cluster.cluster_alias] = cluster.value; + } + }, + ); + allProjects = [ + ...new Set( + projectsData.data?.map((project: { value: string }) => project.value), + ), + ].filter(p => p !== undefined) as string[]; + + await Promise.all([ + cache.set(COST_CLUSTERS_CACHE_KEY, clusterDataMap, { ttl: CACHE_TTL }), + cache.set(COST_PROJECTS_CACHE_KEY, allProjects, { ttl: CACHE_TTL }), + ]); + } + + const { authorizedClusterIds, authorizedClusterProjects } = + await filterAuthorizedClustersAndProjects( + req, + permissions, + httpAuth, + clusterDataMap, + allProjects, + 'cost', + ); + + const finalClusterNames = [ + ...new Set([ + ...authorizedClusterIds, + ...authorizedClusterProjects.map(r => r.cluster), + ]), + ]; + const finalProjects = authorizedClusterProjects.map(r => r.project); + + if (finalClusterNames.length === 0) { + return { + decision: 'DENY', + clusterFilters: [], + projectFilters: [], + filterStyle: 'cost', + }; + } + + return { + decision: 'ALLOW', + clusterFilters: finalClusterNames, + projectFilters: finalProjects, + filterStyle: 'cost', + }; +} + +/** + * Server-side proxy that keeps the SSO token on the backend and enforces + * RBAC before forwarding requests to the Cost Management API. + * + * Replaces the previous architecture where the frontend received the SSO + * token via GET /token and called the Backstage proxy directly. + */ +export const secureProxy: (options: RouterOptions) => RequestHandler = + options => async (req, res) => { + const { logger, config } = options; + const proxyPath = req.params[0]; + + if (!proxyPath) { + return res.status(400).json({ error: 'Missing proxy path' }); + } + + try { + const access = await resolveAccess(req, proxyPath, options); + + if (access.decision !== 'ALLOW') { + return res.status(403).json({ error: 'Access denied by RBAC policy' }); + } + + const token = await getTokenFromApi(options); + + const targetBase = + config?.getOptionalString('costManagementProxyBaseUrl') ?? + DEFAULT_COST_MANAGEMENT_PROXY_BASE_URL; + const targetUrl = new URL( + `${targetBase}/cost-management/v1/${proxyPath}`, + ); + + // Express qs parser converts bracket keys like filter[time_scope_value] + // into nested objects, losing the flat key format the RHCC API expects. + // Use the raw query string to preserve the original key names. + const rbacControlledPatterns = + access.filterStyle === 'ros' + ? [/^cluster=/m, /^project=/m] + : [/^filter\[exact:cluster\]=/m, /^filter\[exact:project\]=/m]; + + const rawQuery = req.originalUrl.split('?')[1] || ''; + const rawParams = rawQuery.split('&').filter(p => p.length > 0); + + for (const param of rawParams) { + const shouldStrip = rbacControlledPatterns.some(pattern => + pattern.test(param), + ); + if (!shouldStrip) { + const eqIdx = param.indexOf('='); + const key = eqIdx >= 0 ? param.substring(0, eqIdx) : param; + const val = eqIdx >= 0 ? param.substring(eqIdx + 1) : ''; + targetUrl.searchParams.append( + decodeURIComponent(key), + decodeURIComponent(val), + ); + } + } + + // Inject server-side RBAC filters (empty arrays = full access, no filter needed) + if (access.clusterFilters.length > 0) { + if (access.filterStyle === 'ros') { + access.clusterFilters.forEach(c => + targetUrl.searchParams.append('cluster', c), + ); + } else { + access.clusterFilters.forEach(c => + targetUrl.searchParams.append('filter[exact:cluster]', c), + ); + } + } + if (access.projectFilters.length > 0) { + if (access.filterStyle === 'ros') { + access.projectFilters.forEach(p => + targetUrl.searchParams.append('project', p), + ); + } else { + access.projectFilters.forEach(p => + targetUrl.searchParams.append('filter[exact:project]', p), + ); + } + } + + logger.info( + `Proxying ${req.method} to ${targetUrl.pathname}${targetUrl.search}`, + ); + + const acceptHeader = req.headers.accept || 'application/json'; + + const upstreamResponse = await fetch(targetUrl.toString(), { + headers: { + 'Content-Type': 'application/json', + Accept: acceptHeader, + Authorization: `Bearer ${token}`, + }, + method: req.method, + }); + + const contentType = upstreamResponse.headers.get('content-type') || ''; + + res.status(upstreamResponse.status); + + if (contentType.includes('application/json')) { + const data = await upstreamResponse.json(); + return res.json(data); + } + + const text = await upstreamResponse.text(); + res.set('Content-Type', contentType); + return res.send(text); + } catch (error) { + logger.error('Secure proxy error', error); + return res.status(500).json({ error: 'Internal proxy error' }); + } + }; diff --git a/workspaces/cost-management/plugins/cost-management-backend/src/service/router.ts b/workspaces/cost-management/plugins/cost-management-backend/src/service/router.ts index 00a28aa616..cfbcd01e9a 100644 --- a/workspaces/cost-management/plugins/cost-management-backend/src/service/router.ts +++ b/workspaces/cost-management/plugins/cost-management-backend/src/service/router.ts @@ -17,11 +17,11 @@ import express from 'express'; import Router from 'express-promise-router'; import type { RouterOptions } from '../models/RouterOptions'; -import { getToken } from '../routes/token'; import { createPermissionIntegrationRouter } from '@backstage/plugin-permission-node'; import { rosPluginPermissions } from '@red-hat-developer-hub/plugin-cost-management-common/permissions'; import { getAccess } from '../routes/access'; import { getCostManagementAccess } from '../routes/costManagementAccess'; +import { secureProxy } from '../routes/secureProxy'; /** @public */ export async function createRouter( @@ -38,11 +38,12 @@ export async function createRouter( router.get('/health', (_req, res) => { res.json({ status: 'ok' }); }); - router.get('/token', getToken(options)); router.get('/access', getAccess(options)); router.get('/access/cost-management', getCostManagementAccess(options)); + router.all('/proxy/*', secureProxy(options)); + return router; } diff --git a/workspaces/cost-management/plugins/cost-management-common/report-clients.api.md b/workspaces/cost-management/plugins/cost-management-common/report-clients.api.md index a3cf61fd61..ea797a8be3 100644 --- a/workspaces/cost-management/plugins/cost-management-common/report-clients.api.md +++ b/workspaces/cost-management/plugins/cost-management-common/report-clients.api.md @@ -153,7 +153,7 @@ export interface CostManagementSlimApi { >; } -// @public (undocumented) +// @public export class CostManagementSlimClient implements CostManagementSlimApi { constructor(options: { discoveryApi: DiscoveryApi; fetchApi?: FetchApi }); // (undocumented) diff --git a/workspaces/cost-management/plugins/cost-management-common/report.api.md b/workspaces/cost-management/plugins/cost-management-common/report.api.md index 1113ffe52d..1e70114aed 100644 --- a/workspaces/cost-management/plugins/cost-management-common/report.api.md +++ b/workspaces/cost-management/plugins/cost-management-common/report.api.md @@ -151,7 +151,7 @@ export interface CostManagementSlimApi { >; } -// @public (undocumented) +// @public export class CostManagementSlimClient implements CostManagementSlimApi { constructor(options: { discoveryApi: DiscoveryApi; fetchApi?: FetchApi }); // (undocumented) diff --git a/workspaces/cost-management/plugins/cost-management-common/src/clients/cost-management/CostManagementSlimClient.ts b/workspaces/cost-management/plugins/cost-management-common/src/clients/cost-management/CostManagementSlimClient.ts index 12a84b94e5..46fb5d3a60 100644 --- a/workspaces/cost-management/plugins/cost-management-common/src/clients/cost-management/CostManagementSlimClient.ts +++ b/workspaces/cost-management/plugins/cost-management-common/src/clients/cost-management/CostManagementSlimClient.ts @@ -24,20 +24,18 @@ import type { DownloadCostManagementRequest, } from './types'; import type { CostManagementSlimApi } from './CostManagementSlimApi'; -import { UnauthorizedError } from '@backstage-community/plugin-rbac-common'; -import { AuthorizeResult } from '@backstage/plugin-permission-common'; -import type { - GetCostManagementAccessResponse, - GetTokenResponse, -} from '../optimizations/types'; - -/** @public */ +/** + * Client for Cost Management data. + * + * In the frontend, requests are routed through the backend plugin's secure + * proxy (`/api/cost-management/proxy/...`), which keeps the SSO token + * server-side and enforces RBAC before forwarding to the Cost Management API. + * + * @public + */ export class CostManagementSlimClient implements CostManagementSlimApi { private readonly discoveryApi: DiscoveryApi; private readonly fetchApi: FetchApi; - private token?: string; - private accessCache?: GetCostManagementAccessResponse; - private accessCacheExpiry?: number; constructor(options: { discoveryApi: DiscoveryApi; fetchApi?: FetchApi }) { this.discoveryApi = options.discoveryApi; @@ -47,82 +45,48 @@ export class CostManagementSlimClient implements CostManagementSlimApi { public async getCostManagementReport( request: GetCostManagementRequest, ): Promise> { - // Ensure access and token - await this.ensureAccessAndToken(); - - // Get the proxy base URL for cost-management API - const baseUrl = await this.discoveryApi.getBaseUrl('proxy'); - const uri = '/cost-management/v1/reports/openshift/costs/'; + const baseUrl = await this.discoveryApi.getBaseUrl(pluginId); + const uri = '/proxy/reports/openshift/costs/'; - // Build query params from the original request const queryParams = this.buildCostManagementQueryParams(request); - // Add RBAC filters - await this.appendRBACFilters(queryParams); - const queryString = queryParams.toString(); const queryPart = queryString ? `?${queryString}` : ''; const url = `${baseUrl}${uri}${queryPart}`; - return await this.fetchWithTokenAndRetry(url); + return await this.fetchViaBackendProxy(url); } public async downloadCostManagementReport( request: DownloadCostManagementRequest, ): Promise { - // Ensure access and token - await this.ensureAccessAndToken(); + const baseUrl = await this.discoveryApi.getBaseUrl(pluginId); + const uri = '/proxy/reports/openshift/costs/'; - // Get the proxy base URL for cost-management API - const baseUrl = await this.discoveryApi.getBaseUrl('proxy'); - const uri = '/cost-management/v1/reports/openshift/costs/'; - - // Build query params, setting limit to 0 to get all results const downloadRequest: GetCostManagementRequest = { query: { ...request.query, - 'filter[limit]': 0, // 0 means no limit - get all results + 'filter[limit]': 0, }, }; - // Remove offset for download delete downloadRequest.query['filter[offset]']; const queryParams = this.buildCostManagementQueryParams(downloadRequest); - // Add RBAC filters - await this.appendRBACFilters(queryParams); - const queryString = queryParams.toString(); const queryPart = queryString ? `?${queryString}` : ''; const url = `${baseUrl}${uri}${queryPart}`; - // Determine content type based on format const acceptHeader = request.format === 'csv' ? 'text/csv' : 'application/json'; - // Fetch with appropriate Accept header - let response = await this.fetchApi.fetch(url, { + const response = await this.fetchApi.fetch(url, { headers: { Accept: acceptHeader, - Authorization: `Bearer ${this.token}`, }, method: 'GET', }); - // Handle 401 errors by refreshing token and retrying - if (!response.ok && response.status === 401) { - const { accessToken } = await this.getNewToken(); - this.token = accessToken; - - response = await this.fetchApi.fetch(url, { - headers: { - Accept: acceptHeader, - Authorization: `Bearer ${this.token}`, - }, - method: 'GET', - }); - } - if (!response.ok) { throw new Error(response.statusText); } @@ -447,9 +411,9 @@ export class CostManagementSlimClient implements CostManagementSlimApi { ): Promise< TypedResponse<{ data: Array<{ value: string }>; meta?: any; links?: any }> > { - const baseUrl = await this.discoveryApi.getBaseUrl('proxy'); + const baseUrl = await this.discoveryApi.getBaseUrl(pluginId); const searchParam = search ? `?search=${encodeURIComponent(search)}` : ''; - const url = `${baseUrl}/cost-management/v1/resource-types/openshift-nodes/${searchParam}`; + const url = `${baseUrl}/proxy/resource-types/openshift-nodes/${searchParam}`; return await this.fetchResourceType(url); } @@ -462,15 +426,10 @@ export class CostManagementSlimClient implements CostManagementSlimApi { public async getOpenShiftTags( timeScopeValue: number = -1, ): Promise> { - // Ensure access and token - await this.ensureAccessAndToken(); - - const baseUrl = await this.discoveryApi.getBaseUrl('proxy'); - const url = await this.buildUrlWithRBACFilters( - `${baseUrl}/cost-management/v1/tags/openshift/?filter[time_scope_value]=${timeScopeValue}&key_only=true&limit=1000`, - ); + const baseUrl = await this.discoveryApi.getBaseUrl(pluginId); + const url = `${baseUrl}/proxy/tags/openshift/?filter[time_scope_value]=${timeScopeValue}&key_only=true&limit=1000`; - return await this.fetchWithTokenAndRetry<{ + return await this.fetchViaBackendProxy<{ data: string[]; meta?: any; links?: any; @@ -493,17 +452,12 @@ export class CostManagementSlimClient implements CostManagementSlimApi { links?: any; }> > { - // Ensure access and token - await this.ensureAccessAndToken(); - - const baseUrl = await this.discoveryApi.getBaseUrl('proxy'); - const url = await this.buildUrlWithRBACFilters( - `${baseUrl}/cost-management/v1/tags/openshift/?filter[key]=${encodeURIComponent( - tagKey, - )}&filter[time_scope_value]=${timeScopeValue}`, - ); + const baseUrl = await this.discoveryApi.getBaseUrl(pluginId); + const url = `${baseUrl}/proxy/tags/openshift/?filter[key]=${encodeURIComponent( + tagKey, + )}&filter[time_scope_value]=${timeScopeValue}`; - return await this.fetchWithTokenAndRetry<{ + return await this.fetchViaBackendProxy<{ data: Array<{ key: string; values: string[]; enabled: boolean }>; meta?: any; links?: any; @@ -517,13 +471,7 @@ export class CostManagementSlimClient implements CostManagementSlimApi { links?: any; }> > { - // Get or refresh token - if (!this.token) { - const { accessToken } = await this.getNewToken(); - this.token = accessToken; - } - - return await this.fetchWithTokenAndRetry<{ + return await this.fetchViaBackendProxy<{ data: Array<{ value: string; cluster_alias: string }>; meta?: any; links?: any; @@ -531,76 +479,14 @@ export class CostManagementSlimClient implements CostManagementSlimApi { } /** - * Ensures user has access and token is available - * @throws UnauthorizedError if access is denied - */ - private async ensureAccessAndToken(): Promise { - const accessAPIResponse = await this.getCostManagementAccess(); - - if (accessAPIResponse.decision === AuthorizeResult.DENY) { - throw new UnauthorizedError(); - } - - if (!this.token) { - const { accessToken } = await this.getNewToken(); - this.token = accessToken; - } - } - - /** - * Appends RBAC cluster and project filters to URLSearchParams - */ - private async appendRBACFilters(queryParams: URLSearchParams): Promise { - const accessAPIResponse = await this.getCostManagementAccess(); - - const authorizedClusters = accessAPIResponse.authorizedClusterNames || []; - if (authorizedClusters.length > 0) { - authorizedClusters.forEach(clusterName => { - queryParams.append('filter[exact:cluster]', clusterName); - }); - } - - const authorizedProjects = accessAPIResponse.authorizeProjects || []; - if (authorizedProjects.length > 0) { - authorizedProjects.forEach(projectName => { - queryParams.append('filter[exact:project]', projectName); - }); - } - } - - /** - * Builds a URL with RBAC filters appended - */ - private async buildUrlWithRBACFilters(baseUrl: string): Promise { - const accessAPIResponse = await this.getCostManagementAccess(); - let url = baseUrl; - - const authorizedClusters = accessAPIResponse.authorizedClusterNames || []; - if (authorizedClusters.length > 0) { - authorizedClusters.forEach(clusterName => { - url += `&filter[exact:cluster]=${encodeURIComponent(clusterName)}`; - }); - } - - const authorizedProjects = accessAPIResponse.authorizeProjects || []; - if (authorizedProjects.length > 0) { - authorizedProjects.forEach(projectName => { - url += `&filter[exact:project]=${encodeURIComponent(projectName)}`; - }); - } - - return url; - } - - /** - * Builds resource type URL with search and limit parameters + * Builds resource type URL routed through the backend secure proxy */ private async buildResourceTypeUrl( resourceType: string, search?: string, limit?: number, ): Promise { - const baseUrl = await this.discoveryApi.getBaseUrl('proxy'); + const baseUrl = await this.discoveryApi.getBaseUrl(pluginId); const params = new URLSearchParams(); if (search) { @@ -612,11 +498,12 @@ export class CostManagementSlimClient implements CostManagementSlimApi { const queryString = params.toString(); const queryPart = queryString ? `?${queryString}` : ''; - return `${baseUrl}/cost-management/v1/resource-types/${resourceType}/${queryPart}`; + return `${baseUrl}/proxy/resource-types/${resourceType}/${queryPart}`; } /** - * Fetches with externally provided token (for backend use) + * Fetches with externally provided token (for backend use). + * This path bypasses the secure proxy since it's already server-side. */ private async fetchWithExternalToken( url: string, @@ -642,36 +529,20 @@ export class CostManagementSlimClient implements CostManagementSlimApi { } /** - * Fetches a URL with token authentication, handles 401 errors by refreshing token and retrying - * @param url - The URL to fetch - * @returns TypedResponse with the response data + * Fetches a URL via the backend secure proxy. No Authorization header + * is sent; the backend injects the SSO token and RBAC filters + * server-side. Authentication is via the Backstage session cookie. */ - private async fetchWithTokenAndRetry( + private async fetchViaBackendProxy( url: string, ): Promise> { - // Call the API via backend proxy - let response = await this.fetchApi.fetch(url, { + const response = await this.fetchApi.fetch(url, { headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${this.token}`, }, method: 'GET', }); - // Handle 401 errors by refreshing token and retrying - if (!response.ok && response.status === 401) { - const { accessToken } = await this.getNewToken(); - this.token = accessToken; - - response = await this.fetchApi.fetch(url, { - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${this.token}`, - }, - method: 'GET', - }); - } - if (!response.ok) { throw new Error(response.statusText); } @@ -684,38 +555,4 @@ export class CostManagementSlimClient implements CostManagementSlimApi { }, }; } - - private async getCostManagementAccess(): Promise { - const now = Date.now(); - const CACHE_TTL = 15 * 60 * 1000; // 15 minutes - matches backend cache - - // Return cached access response if still valid - if ( - this.accessCache && - this.accessCacheExpiry && - this.accessCacheExpiry > now - ) { - return this.accessCache; - } - - // Fetch fresh access data - const baseUrl = await this.discoveryApi.getBaseUrl(`${pluginId}`); - const response = await this.fetchApi.fetch( - `${baseUrl}/access/cost-management`, - ); - const data = (await response.json()) as GetCostManagementAccessResponse; - - // Cache the response - this.accessCache = data; - this.accessCacheExpiry = now + CACHE_TTL; - - return data; - } - - private async getNewToken(): Promise { - const baseUrl = await this.discoveryApi.getBaseUrl(`${pluginId}`); - const response = await this.fetchApi.fetch(`${baseUrl}/token`); - const data = (await response.json()) as GetTokenResponse; - return data; - } } diff --git a/workspaces/cost-management/plugins/cost-management-common/src/clients/optimizations/OptimizationsClient.test.ts b/workspaces/cost-management/plugins/cost-management-common/src/clients/optimizations/OptimizationsClient.test.ts index cc1cc71a3b..146a3cfb48 100644 --- a/workspaces/cost-management/plugins/cost-management-common/src/clients/optimizations/OptimizationsClient.test.ts +++ b/workspaces/cost-management/plugins/cost-management-common/src/clients/optimizations/OptimizationsClient.test.ts @@ -35,25 +35,13 @@ function makePlotsDataPropertyPathWithTerm( ]; } -const MOCK_BASE_URL = 'https://backstage:1234/api/proxy'; +const MOCK_BASE_URL = 'https://backstage:1234/api/cost-management'; const mockDiscoveryApi: DiscoveryApi = { async getBaseUrl(_pluginId: string): Promise { return MOCK_BASE_URL; }, }; -const server = setupServer( - http.get(`${MOCK_BASE_URL}/token`, _info => - HttpResponse.json({ - accessToken: 'hereisyourtokensir', - expiresAt: 1234567890, - }), - ), - http.get(`${MOCK_BASE_URL}/access`, _info => - HttpResponse.json({ - decision: 'ALLOW', - }), - ), -); +const server = setupServer(); // eslint-disable-next-line jest/no-disabled-tests describe('OptimizationsApiClientProxy.ts', () => { @@ -77,7 +65,7 @@ describe('OptimizationsApiClientProxy.ts', () => { // Arrange server.use( http.get( - `${MOCK_BASE_URL}/cost-management/v1/recommendations/openshift/:id`, + `${MOCK_BASE_URL}/proxy/recommendations/openshift/:id`, _info => HttpResponse.json(RecommendationMockResponse), ), ); @@ -112,14 +100,14 @@ describe('OptimizationsApiClientProxy.ts', () => { expect.assertions(2); // Arrange - const spyOnDefaultClientGetRecommendationById = jest.spyOn( - // @ts-ignore (because defaultClient is private 🤫) - client.defaultClient, + const spyOnProxyClientGetRecommendationById = jest.spyOn( + // @ts-ignore (because proxyClient is private 🤫) + client.proxyClient, 'getRecommendationById', ); server.use( http.get( - `${MOCK_BASE_URL}/cost-management/v1/recommendations/openshift/:id`, + `${MOCK_BASE_URL}/proxy/recommendations/openshift/:id`, _info => HttpResponse.json(RecommendationMockResponse), ), ); @@ -133,10 +121,10 @@ describe('OptimizationsApiClientProxy.ts', () => { // Assert expect( - spyOnDefaultClientGetRecommendationById.mock.lastCall?.[0], + spyOnProxyClientGetRecommendationById.mock.lastCall?.[0], ).toHaveProperty('path.recommendationId'); expect( - spyOnDefaultClientGetRecommendationById.mock.lastCall?.[0], + spyOnProxyClientGetRecommendationById.mock.lastCall?.[0], ).toHaveProperty('query.cpu_unit'); }); }); diff --git a/workspaces/cost-management/plugins/cost-management-common/src/clients/optimizations/OptimizationsClient.ts b/workspaces/cost-management/plugins/cost-management-common/src/clients/optimizations/OptimizationsClient.ts index 2fc05502cd..f40a8110c3 100644 --- a/workspaces/cost-management/plugins/cost-management-common/src/clients/optimizations/OptimizationsClient.ts +++ b/workspaces/cost-management/plugins/cost-management-common/src/clients/optimizations/OptimizationsClient.ts @@ -16,10 +16,8 @@ import type { DiscoveryApi, FetchApi } from '@backstage/core-plugin-api'; import { deepMapKeys } from '../../util/mod'; -import crossFetch from 'cross-fetch'; import camelCase from 'lodash/camelCase'; import snakeCase from 'lodash/snakeCase'; -import merge from 'lodash/merge'; import { pluginId } from '../../generated/pluginId'; import { DefaultApiClient, @@ -33,28 +31,18 @@ import type { import type { GetRecommendationByIdRequest, GetRecommendationListRequest, - GetTokenResponse, OptimizationsApi, - GetAccessResponse, } from './types'; -import { UnauthorizedError } from '@backstage-community/plugin-rbac-common'; -import { AuthorizeResult } from '@backstage/plugin-permission-common'; - -type DefaultApiClientOpFunc< - TRequest = GetRecommendationByIdRequest | GetRecommendationListRequest, - TResponse = RecommendationBoxPlots | RecommendationList, -> = ( - this: DefaultApiClient, - request: TRequest, - options?: RequestOptions, -) => Promise>; /** - * This class is a proxy for the original Optimizations client. - * It provides the following additional functionality: - * 1. Routes calls through the backend's proxy. - * 2. Implements a token renewal mechanism. - * 3. Handles case conversion + * Client for the Optimizations (ROS) API. + * + * In the frontend, requests are routed through the backend plugin's secure + * proxy (`/api/cost-management/proxy/...`), which keeps the SSO token + * server-side and enforces RBAC before forwarding to the Cost Management API. + * + * When a `token` is provided via `RequestOptions` (backend use-case), the + * request goes directly to the upstream API. * * @public */ @@ -68,25 +56,33 @@ export class OptimizationsClient implements OptimizationsApi { ], }; - private readonly discoveryApi: DiscoveryApi; - private readonly fetchApi: FetchApi; - private readonly defaultClient: DefaultApiClient; - private token?: string; - private clusterIds?: string[]; - private projectNames?: string[]; + /** Used for backend-to-RHCC calls (token provided by caller). */ + private readonly directClient: DefaultApiClient; + /** Used for frontend calls routed through the backend secure proxy. */ + private readonly proxyClient: DefaultApiClient; constructor(options: { discoveryApi: DiscoveryApi; fetchApi?: FetchApi }) { - this.defaultClient = new DefaultApiClient({ + const outerDiscovery = options.discoveryApi; + + this.directClient = new DefaultApiClient({ fetchApi: options.fetchApi, discoveryApi: { async getBaseUrl() { - const baseUrl = await options.discoveryApi.getBaseUrl('proxy'); + const baseUrl = await outerDiscovery.getBaseUrl('proxy'); return `${baseUrl}/cost-management/v1`; }, }, }); - this.discoveryApi = options.discoveryApi; - this.fetchApi = options.fetchApi ?? { fetch: crossFetch }; + + this.proxyClient = new DefaultApiClient({ + fetchApi: options.fetchApi, + discoveryApi: { + async getBaseUrl() { + const baseUrl = await outerDiscovery.getBaseUrl(pluginId); + return `${baseUrl}/proxy`; + }, + }, + }); } public async getRecommendationById( @@ -97,11 +93,14 @@ export class OptimizationsClient implements OptimizationsApi { skipList: OptimizationsClient.requestKeysToSkip.getRecommendationById, }) as GetRecommendationByIdRequest; - const response = await this.fetchWithToken( - this.defaultClient.getRecommendationById, + const response = await this.proxyClient.getRecommendationById( snakeCaseTransformedRequest, ); + if (!response.ok) { + throw new Error(response.statusText); + } + return { ...response, json: async () => { @@ -125,32 +124,18 @@ export class OptimizationsClient implements OptimizationsApi { snakeCase as (value: string | number) => string, ) as GetRecommendationListRequest; - // If token is provided in options (backend use case), skip access check - if (options?.token) { - const response = await this.defaultClient.getRecommendationList( - snakeCaseTransformedRequest, - options, - ); - - return { - ...response, - json: async () => { - const data = await response.json(); - const camelCaseTransformedResponse = deepMapKeys( - data, - camelCase as (value: string | number) => string, - ) as RecommendationList; - return camelCaseTransformedResponse; - }, - }; - } + // Backend use-case: token provided, call RHCC directly + const client = options?.token ? this.directClient : this.proxyClient; - // Frontend use case - use fetchWithToken which includes access check - const response = await this.fetchWithToken( - this.defaultClient.getRecommendationList, + const response = await client.getRecommendationList( snakeCaseTransformedRequest, + options, ); + if (!response.ok) { + throw new Error(response.statusText); + } + return { ...response, json: async () => { @@ -163,78 +148,4 @@ export class OptimizationsClient implements OptimizationsApi { }, }; } - - private async getAccess(): Promise { - const baseUrl = await this.discoveryApi.getBaseUrl(`${pluginId}`); - const response = await this.fetchApi.fetch(`${baseUrl}/access`); - const data = (await response.json()) as GetAccessResponse; - return data; - } - - private async getNewToken(): Promise { - const baseUrl = await this.discoveryApi.getBaseUrl(`${pluginId}`); - const response = await this.fetchApi.fetch(`${baseUrl}/token`); - const data = (await response.json()) as GetTokenResponse; - return data; - } - - private async fetchWithToken< - TRequest = GetRecommendationByIdRequest | GetRecommendationListRequest, - TResponse = RecommendationBoxPlots | RecommendationList, - >( - asyncOp: DefaultApiClientOpFunc, - request: TRequest, - ): Promise> { - const accessAPIResponse = await this.getAccess(); - - if (accessAPIResponse.decision === AuthorizeResult.DENY) { - const error = new UnauthorizedError(); - throw error; - } - - const { authorizeClusterIds, authorizeProjects } = accessAPIResponse; - this.clusterIds = authorizeClusterIds; - this.projectNames = authorizeProjects; - - const clusterParams = { - query: { - cluster: this.clusterIds, - project: this.projectNames, - }, - }; - - if (!this.token) { - const { accessToken } = await this.getNewToken(); - this.token = accessToken; - } - - let response = await asyncOp.call( - this.defaultClient, - merge({}, request, clusterParams), - { - token: this.token, - }, - ); - - if (!response.ok) { - if (response.status === 401) { - const { accessToken } = await this.getNewToken(); - this.token = accessToken; - - response = await asyncOp.call(this.defaultClient, request, { - token: this.token, - }); - } else { - throw new Error(response.statusText); - } - } - - return { - ...response, - json: async () => { - const data = (await response.json()) as TResponse; - return data; - }, - }; - } } From 39f41a3879e1b5c43027ea212ec0a4c3cc781a2c Mon Sep 17 00:00:00 2001 From: gharden Date: Thu, 26 Mar 2026 11:45:48 -0400 Subject: [PATCH 2/8] docs(cost-management): update documentation for server-side proxy architecture - Remove all references to dangerously-allow-unauthenticated proxy configuration - Document new secure backend proxy architecture and endpoints - Update static and dynamic plugin setup instructions - Add server-side RBAC enforcement explanation to rbac.md Made-with: Cursor --- .../cost-management/docs/dynamic-plugin.md | 55 ++++++++-------- workspaces/cost-management/docs/rbac.md | 15 ++++- .../plugins/cost-management-backend/README.md | 42 ++++++++++++- .../plugins/cost-management/README.md | 63 ++++--------------- 4 files changed, 93 insertions(+), 82 deletions(-) diff --git a/workspaces/cost-management/docs/dynamic-plugin.md b/workspaces/cost-management/docs/dynamic-plugin.md index 95630784a3..302f8229af 100644 --- a/workspaces/cost-management/docs/dynamic-plugin.md +++ b/workspaces/cost-management/docs/dynamic-plugin.md @@ -33,32 +33,31 @@ The procedure involves the following steps: ```yaml # Add to dynamic-plugins-rhdh ConfigMap - - package: oci://quay.io/redhat-resource-optimization/dynamic-plugins:1.2.0!red-hat-developer-hub-plugin-cost-management - disabled: false - pluginConfig: - dynamicPlugins: - frontend: - backstage-community.plugin-cost-management: - appIcons: - - name: costManagementIconOutlined - importName: CostManagementIconOutlined - dynamicRoutes: - - path: /cost-management/optimizations - importName: ResourceOptimizationPage - menuItem: - icon: costManagementIconOutlined - text: Optimizations - - package: oci://quay.io/redhat-resource-optimization/dynamic-plugins:1.2.0!red-hat-developer-hub-plugin-cost-management-backend - disabled: false - pluginConfig: - proxy: - endpoints: - '/cost-management/v1': - target: https://console.redhat.com/api/cost-management/v1 - allowedHeaders: ['Authorization'] - credentials: dangerously-allow-unauthenticated - costManagement: - clientId: ${CM_CLIENT_ID} - clientSecret: ${CM_CLIENT_SECRET} - optimizationWorkflowId: 'patch-k8s-resource' + - package: oci://quay.io/redhat-resource-optimization/dynamic-plugins:latest!red-hat-developer-hub-plugin-cost-management + disabled: false + pluginConfig: + dynamicPlugins: + frontend: + backstage-community.plugin-cost-management: + appIcons: + - name: costManagementIconOutlined + importName: CostManagementIconOutlined + dynamicRoutes: + - path: /cost-management/optimizations + importName: ResourceOptimizationPage + menuItem: + icon: costManagementIconOutlined + text: Optimizations + - package: oci://quay.io/redhat-resource-optimization/dynamic-plugins:latest!red-hat-developer-hub-plugin-cost-management-backend + disabled: false + pluginConfig: + costManagement: + clientId: ${CM_CLIENT_ID} + clientSecret: ${CM_CLIENT_SECRET} + optimizationWorkflowId: 'patch-k8s-resource' ``` + + > **Note:** No `proxy` configuration is required. The backend plugin communicates + > with the Red Hat Cost Management API server-side. SSO tokens are obtained + > internally via OAuth2 `client_credentials` grant and never exposed to the + > browser. RBAC filtering is enforced server-side before data is returned. diff --git a/workspaces/cost-management/docs/rbac.md b/workspaces/cost-management/docs/rbac.md index 2faf5e6f26..20e4403346 100644 --- a/workspaces/cost-management/docs/rbac.md +++ b/workspaces/cost-management/docs/rbac.md @@ -1,10 +1,23 @@ -The Cost Management plugin protects its backend endpoints with the builtin permission mechanism and combines it with the RBAC plugin. +The Cost Management plugin protects its backend endpoints with the builtin permission mechanism and combines it with the RBAC plugin. All permission checks are enforced **server-side** within the backend plugin's secure proxy — the frontend never receives data the user is not authorized to see, and RBAC filters cannot be bypassed by modifying client requests. The Cost Management plugin consists of two main sections, each with its own set of permissions: - **Optimizations**: Uses permissions starting with `ros.` - **OpenShift**: Uses permissions starting with `cost.` +### How it works + +When a frontend request arrives at `/api/cost-management/proxy/*`, the backend: + +1. Authenticates the user via Backstage `httpAuth` +2. Evaluates the user's permissions against the `ros.*` or `cost.*` policy +3. Determines the authorized list of clusters and projects +4. Strips any client-supplied cluster/project filter parameters +5. Injects the server-authorized filters before forwarding to the upstream API +6. Returns only the data the user is permitted to see + +This means granting `ros.demolab` only allows seeing data for the `demolab` cluster — the user cannot modify query parameters to access other clusters. + ## 1. Optimizations Section The Optimizations section allows users to view resource usage trends and optimization recommendations for workloads running on OpenShift clusters. diff --git a/workspaces/cost-management/plugins/cost-management-backend/README.md b/workspaces/cost-management/plugins/cost-management-backend/README.md index ddb59b7afc..611309a036 100644 --- a/workspaces/cost-management/plugins/cost-management-backend/README.md +++ b/workspaces/cost-management/plugins/cost-management-backend/README.md @@ -1,8 +1,46 @@ # Resource Optimization back-end plugin -Welcome to the cost-management backend plugin! +The cost-management backend plugin provides a secure server-side proxy for the +Red Hat Cost Management API. All communication with the upstream API happens +within the backend — SSO tokens never leave the server and RBAC filtering is +enforced before data is returned to the browser. -_This plugin was created through the Backstage CLI_ +## Architecture + +The plugin exposes a single catch-all route at `/api/cost-management/proxy/*`. +When a request arrives the handler: + +1. **Authenticates** the caller via Backstage `httpAuth` (requires a valid user session). +2. **Checks permissions** through the Backstage permission framework using the + `ros.*` and `cost.*` permission sets (see [docs/rbac.md](../../docs/rbac.md)). +3. **Obtains an SSO token** internally via the OAuth2 `client_credentials` grant + using the `costManagement.clientId` / `costManagement.clientSecret` from + `app-config`. +4. **Strips** any client-supplied RBAC-controlled query parameters (`cluster`, + `project`, `filter[exact:cluster]`, `filter[exact:project]`). +5. **Injects** server-authorised cluster/project filters from the permission + decision. +6. **Forwards** the request to the Red Hat Cost Management API and streams the + response back. + +### Endpoints + +| Path | Auth | Description | +| ---------------------------------- | ------------- | ----------------------------------- | +| `GET /api/cost-management/health` | None | Health check | +| `ALL /api/cost-management/proxy/*` | `user-cookie` | Secure proxy to Cost Management API | + +### Configuration + +```yaml +# app-config.yaml +costManagement: + clientId: ${RHHCC_SA_CLIENT_ID} + clientSecret: ${RHHCC_SA_CLIENT_SECRET} +``` + +No `proxy` block with `dangerously-allow-unauthenticated` is needed — the +backend plugin handles upstream communication directly. ## Getting started diff --git a/workspaces/cost-management/plugins/cost-management/README.md b/workspaces/cost-management/plugins/cost-management/README.md index 5d22fb9a79..26af4e76f6 100644 --- a/workspaces/cost-management/plugins/cost-management/README.md +++ b/workspaces/cost-management/plugins/cost-management/README.md @@ -28,20 +28,16 @@ You can follow one of these options depending on your environment and how you ch ```yaml # app-config.yaml - proxy: - endpoints: - '/cost-management/v1': - target: https://console.redhat.com/api/cost-management/v1 - allowedHeaders: ['Authorization'] - # See: https://backstage.io/docs/releases/v1.28.0/#breaking-proxy-backend-plugin-protected-by-default - credentials: dangerously-allow-unauthenticated - # Replace `${RHHCC_SA_CLIENT_ID}` and `${RHHCC_SA_CLIENT_SECRET}` with the service account credentials. costManagement: clientId: ${RHHCC_SA_CLIENT_ID} clientSecret: ${RHHCC_SA_CLIENT_SECRET} ``` + > **Note:** No `proxy` configuration is required. The backend plugin handles all + > communication with the Red Hat Cost Management API server-side, including SSO + > token management and RBAC enforcement. + 1. Add the back-end plugin to `packages/backend/src/index.ts` ```ts @@ -138,51 +134,16 @@ The procedure involves the following steps: ```yaml # Add to app-config-rhdh ConfigMap - proxy: - endpoints: - '/cost-management/v1': - target: https://console.redhat.com/api/cost-management/v1 - allowedHeaders: ['Authorization'] - credentials: dangerously-allow-unauthenticated - costManagement: - clientId: '${RHHCC_SA_CLIENT_ID}' - clientSecret: '${RHHCC_SA_CLIENT_SECRET}' - dynamicPlugins: - frontend: - red-hat-developer-hub.plugin-cost-management: - appIcons: + costManagement: + clientId: '${RHHCC_SA_CLIENT_ID}' + clientSecret: '${RHHCC_SA_CLIENT_SECRET}' ``` -- name: costManagementIconOutlined - importName: CostManagementIconOutlined - routeBindings: - targets: - name: resourceOptimizationPlugin - dynamicRoutes: - path: /cost-management/optimizations - importName: ResourceOptimizationPage - menuItem: - icon: costManagementIconOutlined - text: Optimizations - - ```` - - ```yaml - # Add to dynamic-plugins-rhdh ConfigMap - - kind: ConfigMap - apiVersion: v1 - metadata # - data: - dynamic-plugins.yaml: | - includes: - - dynamic-plugins.default.yaml - plugins: - - package: '@marek.libra/plugin-cost-management-dynamic@1.0.1' - integrity: 'sha512-w53eSjMAUmKG2nwYeq+6B63qPeAqmSz2C4NsBaMleV4A8ST05yht/UK2pgHJTpxtLo0CYSq/+plR3s47xhO0aQ==' - disabled: false - - package: '@marek.libra/plugin-cost-management-backend-dynamic@1.0.0' - integrity: 'sha512-ndhUnXGJUdLX1FubdCW/I8uE5oq5I0f/R/dSNGsCqD6Y/Uvcja5y8DE8W8hI+t2GnnEttuxehmjTBbjAT7sQRQ==' - disabled: false - ```` + > **Note:** No `proxy` configuration is required. The backend plugin handles all + > communication with the Red Hat Cost Management API server-side, including SSO + > token management and RBAC enforcement. + + See [dynamic-plugin.md](../docs/dynamic-plugin.md) for complete dynamic plugin configuration. ### Contributing From c092f79d2980071a3893591d0b122c116908e5eb Mon Sep 17 00:00:00 2001 From: gharden Date: Thu, 26 Mar 2026 11:47:32 -0400 Subject: [PATCH 3/8] fix(cost-management): address review - path traversal and encoded RBAC bypass - Reject proxyPath containing dot-segments (../) or leading slashes to prevent path traversal beyond /cost-management/v1/ - Post-construction check ensures resolved pathname stays under the base path - Decode query parameter keys before RBAC matching so percent-encoded variants (e.g. filter%5Bexact%3Acluster%5D) are correctly stripped - Replace regex-based stripping with Set-based decoded key lookup Made-with: Cursor --- .../src/routes/secureProxy.ts | 45 +++++++++++-------- 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/workspaces/cost-management/plugins/cost-management-backend/src/routes/secureProxy.ts b/workspaces/cost-management/plugins/cost-management-backend/src/routes/secureProxy.ts index f16cb869fb..bc4d357fc4 100644 --- a/workspaces/cost-management/plugins/cost-management-backend/src/routes/secureProxy.ts +++ b/workspaces/cost-management/plugins/cost-management-backend/src/routes/secureProxy.ts @@ -277,6 +277,12 @@ export const secureProxy: (options: RouterOptions) => RequestHandler = return res.status(400).json({ error: 'Missing proxy path' }); } + if (/(?:^|\/)\.\.(\/|$)/.test(proxyPath) || proxyPath.startsWith('/')) { + return res + .status(400) + .json({ error: 'Invalid proxy path: traversal not allowed' }); + } + try { const access = await resolveAccess(req, proxyPath, options); @@ -289,33 +295,36 @@ export const secureProxy: (options: RouterOptions) => RequestHandler = const targetBase = config?.getOptionalString('costManagementProxyBaseUrl') ?? DEFAULT_COST_MANAGEMENT_PROXY_BASE_URL; - const targetUrl = new URL( - `${targetBase}/cost-management/v1/${proxyPath}`, - ); + const basePath = `${targetBase}/cost-management/v1/`; + const targetUrl = new URL(proxyPath, basePath); + + if (!targetUrl.pathname.startsWith(new URL(basePath).pathname)) { + return res + .status(400) + .json({ error: 'Invalid proxy path: traversal not allowed' }); + } // Express qs parser converts bracket keys like filter[time_scope_value] // into nested objects, losing the flat key format the RHCC API expects. - // Use the raw query string to preserve the original key names. - const rbacControlledPatterns = + // Decode each param before matching so percent-encoded variants of + // RBAC-controlled keys (e.g. filter%5Bexact%3Acluster%5D) are also caught. + const rbacControlledKeys = new Set( access.filterStyle === 'ros' - ? [/^cluster=/m, /^project=/m] - : [/^filter\[exact:cluster\]=/m, /^filter\[exact:project\]=/m]; + ? ['cluster', 'project'] + : ['filter[exact:cluster]', 'filter[exact:project]'], + ); const rawQuery = req.originalUrl.split('?')[1] || ''; const rawParams = rawQuery.split('&').filter(p => p.length > 0); for (const param of rawParams) { - const shouldStrip = rbacControlledPatterns.some(pattern => - pattern.test(param), - ); - if (!shouldStrip) { - const eqIdx = param.indexOf('='); - const key = eqIdx >= 0 ? param.substring(0, eqIdx) : param; - const val = eqIdx >= 0 ? param.substring(eqIdx + 1) : ''; - targetUrl.searchParams.append( - decodeURIComponent(key), - decodeURIComponent(val), - ); + const eqIdx = param.indexOf('='); + const rawKey = eqIdx >= 0 ? param.substring(0, eqIdx) : param; + const rawVal = eqIdx >= 0 ? param.substring(eqIdx + 1) : ''; + const decodedKey = decodeURIComponent(rawKey); + + if (!rbacControlledKeys.has(decodedKey)) { + targetUrl.searchParams.append(decodedKey, decodeURIComponent(rawVal)); } } From 75d3be7c7326b4fe9359c51c31dc4ddcf79dc8e5 Mon Sep 17 00:00:00 2001 From: gharden Date: Thu, 26 Mar 2026 11:50:33 -0400 Subject: [PATCH 4/8] refactor(cost-management): extract common RBAC resolution to reduce duplication Extract resolveAccessForSection() to eliminate structural duplication between resolveOptimizationsAccess and resolveCostManagementAccess. Each section now provides only a data-fetching callback while the common authorize-cache-filter logic lives in one place. Made-with: Cursor --- .../src/routes/secureProxy.ts | 255 ++++++++---------- 1 file changed, 111 insertions(+), 144 deletions(-) diff --git a/workspaces/cost-management/plugins/cost-management-backend/src/routes/secureProxy.ts b/workspaces/cost-management/plugins/cost-management-backend/src/routes/secureProxy.ts index bc4d357fc4..e2ce68a88e 100644 --- a/workspaces/cost-management/plugins/cost-management-backend/src/routes/secureProxy.ts +++ b/workspaces/cost-management/plugins/cost-management-backend/src/routes/secureProxy.ts @@ -54,15 +54,38 @@ async function resolveAccess( return resolveCostManagementAccess(req, options); } -async function resolveOptimizationsAccess( +interface CacheConfig { + clusterKey: string; + projectKey: string; +} + +type DataFetcher = ( + options: RouterOptions, +) => Promise<{ clusters: Record; projects: string[] } | null>; + +/** + * Common RBAC resolution: check plugin-level permission, fetch + cache + * cluster/project data, filter authorised entries, and return the result. + */ +async function resolveAccessForSection( req: Request, options: RouterOptions, + pluginPerms: typeof rosPluginPermissions, + filterStyle: AccessResult['filterStyle'], + cacheKeys: CacheConfig, + fetchData: DataFetcher, ): Promise { - const { permissions, httpAuth, cache, optimizationApi } = options; + const { permissions, httpAuth, cache } = options; + const deny = (): AccessResult => ({ + decision: 'DENY', + clusterFilters: [], + projectFilters: [], + filterStyle, + }); const pluginDecision = await authorize( req, - rosPluginPermissions, + pluginPerms, permissions, httpAuth, ); @@ -71,59 +94,34 @@ async function resolveOptimizationsAccess( decision: 'ALLOW', clusterFilters: [], projectFilters: [], - filterStyle: 'ros', + filterStyle, }; } - const ALL_CLUSTERS_MAP_CACHE_KEY = 'all_clusters_map'; - const ALL_PROJECTS_CACHE_KEY = 'all_projects'; - let clusterDataMap: Record = {}; let allProjects: string[] = []; - const clusterMapDataFromCache = (await cache.get( - ALL_CLUSTERS_MAP_CACHE_KEY, - )) as Record | undefined; - const projectDataFromCache = (await cache.get(ALL_PROJECTS_CACHE_KEY)) as + const cachedClusters = (await cache.get(cacheKeys.clusterKey)) as + | Record + | undefined; + const cachedProjects = (await cache.get(cacheKeys.projectKey)) as | string[] | undefined; - if (clusterMapDataFromCache && projectDataFromCache) { - clusterDataMap = clusterMapDataFromCache; - allProjects = projectDataFromCache; + if (cachedClusters && cachedProjects) { + clusterDataMap = cachedClusters; + allProjects = cachedProjects; } else { - const token = await getTokenFromApi(options); - const optimizationResponse = await optimizationApi.getRecommendationList( - { query: { limit: -1, orderHow: 'desc', orderBy: 'last_reported' } }, - { token }, - ); - const recommendationList = await optimizationResponse.json(); - - if ((recommendationList as any).errors) { - return { - decision: 'DENY', - clusterFilters: [], - projectFilters: [], - filterStyle: 'ros', - }; - } + const result = await fetchData(options); + if (!result) return deny(); - if (recommendationList.data) { - recommendationList.data.forEach(recommendation => { - if (recommendation.clusterAlias && recommendation.clusterUuid) { - clusterDataMap[recommendation.clusterAlias] = - recommendation.clusterUuid; - } - }); - allProjects = [ - ...new Set(recommendationList.data.map(r => r.project)), - ].filter(p => p !== undefined) as string[]; + clusterDataMap = result.clusters; + allProjects = result.projects; - await cache.set(ALL_CLUSTERS_MAP_CACHE_KEY, clusterDataMap, { - ttl: CACHE_TTL, - }); - await cache.set(ALL_PROJECTS_CACHE_KEY, allProjects, { ttl: CACHE_TTL }); - } + await Promise.all([ + cache.set(cacheKeys.clusterKey, clusterDataMap, { ttl: CACHE_TTL }), + cache.set(cacheKeys.projectKey, allProjects, { ttl: CACHE_TTL }), + ]); } const { authorizedClusterIds, authorizedClusterProjects } = @@ -133,9 +131,10 @@ async function resolveOptimizationsAccess( httpAuth, clusterDataMap, allProjects, + ...(filterStyle === 'cost' ? (['cost'] as const) : []), ); - const finalClusterIds = [ + const finalClusters = [ ...new Set([ ...authorizedClusterIds, ...authorizedClusterProjects.map(r => r.cluster), @@ -143,122 +142,90 @@ async function resolveOptimizationsAccess( ]; const finalProjects = authorizedClusterProjects.map(r => r.project); - if (finalClusterIds.length === 0) { - return { - decision: 'DENY', - clusterFilters: [], - projectFilters: [], - filterStyle: 'ros', - }; - } + if (finalClusters.length === 0) return deny(); return { decision: 'ALLOW', - clusterFilters: finalClusterIds, + clusterFilters: finalClusters, projectFilters: finalProjects, - filterStyle: 'ros', + filterStyle, }; } -async function resolveCostManagementAccess( +async function resolveOptimizationsAccess( req: Request, options: RouterOptions, ): Promise { - const { permissions, httpAuth, cache, costManagementApi } = options; - - const pluginDecision = await authorize( + return resolveAccessForSection( req, - costPluginPermissions, - permissions, - httpAuth, - ); - if (pluginDecision.result === AuthorizeResult.ALLOW) { - return { - decision: 'ALLOW', - clusterFilters: [], - projectFilters: [], - filterStyle: 'cost', - }; - } - - const COST_CLUSTERS_CACHE_KEY = 'cost_clusters'; - const COST_PROJECTS_CACHE_KEY = 'cost_projects'; - - let clusterDataMap: Record = {}; - let allProjects: string[] = []; - - const clustersFromCache = (await cache.get(COST_CLUSTERS_CACHE_KEY)) as - | Record - | undefined; - const projectsFromCache = (await cache.get(COST_PROJECTS_CACHE_KEY)) as - | string[] - | undefined; - - if (clustersFromCache && projectsFromCache) { - clusterDataMap = clustersFromCache; - allProjects = projectsFromCache; - } else { - const token = await getTokenFromApi(options); - const [clustersResponse, projectsResponse] = await Promise.all([ - costManagementApi.searchOpenShiftClusters('', { token, limit: 1000 }), - costManagementApi.searchOpenShiftProjects('', { token, limit: 1000 }), - ]); + options, + rosPluginPermissions, + 'ros', + { clusterKey: 'all_clusters_map', projectKey: 'all_projects' }, + async opts => { + const token = await getTokenFromApi(opts); + const response = await opts.optimizationApi.getRecommendationList( + { query: { limit: -1, orderHow: 'desc', orderBy: 'last_reported' } }, + { token }, + ); + const list = await response.json(); - const clustersData = await clustersResponse.json(); - const projectsData = await projectsResponse.json(); + if ((list as any).errors || !list.data) return null; - clustersData.data?.forEach( - (cluster: { value: string; cluster_alias: string }) => { - if (cluster.cluster_alias && cluster.value) { - clusterDataMap[cluster.cluster_alias] = cluster.value; + const clusters: Record = {}; + list.data.forEach(r => { + if (r.clusterAlias && r.clusterUuid) { + clusters[r.clusterAlias] = r.clusterUuid; } - }, - ); - allProjects = [ - ...new Set( - projectsData.data?.map((project: { value: string }) => project.value), - ), - ].filter(p => p !== undefined) as string[]; - - await Promise.all([ - cache.set(COST_CLUSTERS_CACHE_KEY, clusterDataMap, { ttl: CACHE_TTL }), - cache.set(COST_PROJECTS_CACHE_KEY, allProjects, { ttl: CACHE_TTL }), - ]); - } - - const { authorizedClusterIds, authorizedClusterProjects } = - await filterAuthorizedClustersAndProjects( - req, - permissions, - httpAuth, - clusterDataMap, - allProjects, - 'cost', - ); + }); + const projects = [...new Set(list.data.map(r => r.project))].filter( + (p): p is string => p !== undefined, + ); - const finalClusterNames = [ - ...new Set([ - ...authorizedClusterIds, - ...authorizedClusterProjects.map(r => r.cluster), - ]), - ]; - const finalProjects = authorizedClusterProjects.map(r => r.project); + return { clusters, projects }; + }, + ); +} - if (finalClusterNames.length === 0) { - return { - decision: 'DENY', - clusterFilters: [], - projectFilters: [], - filterStyle: 'cost', - }; - } +async function resolveCostManagementAccess( + req: Request, + options: RouterOptions, +): Promise { + return resolveAccessForSection( + req, + options, + costPluginPermissions, + 'cost', + { clusterKey: 'cost_clusters', projectKey: 'cost_projects' }, + async opts => { + const token = await getTokenFromApi(opts); + const [clustersResp, projectsResp] = await Promise.all([ + opts.costManagementApi.searchOpenShiftClusters('', { + token, + limit: 1000, + }), + opts.costManagementApi.searchOpenShiftProjects('', { + token, + limit: 1000, + }), + ]); + + const clustersData = await clustersResp.json(); + const projectsData = await projectsResp.json(); + + const clusters: Record = {}; + clustersData.data?.forEach( + (c: { value: string; cluster_alias: string }) => { + if (c.cluster_alias && c.value) clusters[c.cluster_alias] = c.value; + }, + ); + const projects = [ + ...new Set(projectsData.data?.map((p: { value: string }) => p.value)), + ].filter((p): p is string => p !== undefined); - return { - decision: 'ALLOW', - clusterFilters: finalClusterNames, - projectFilters: finalProjects, - filterStyle: 'cost', - }; + return { clusters, projects }; + }, + ); } /** From 464701891e5bb0180d8d44f848df97a29a2790ea Mon Sep 17 00:00:00 2001 From: gharden Date: Thu, 26 Mar 2026 20:09:23 -0400 Subject: [PATCH 5/8] =?UTF-8?q?fix(cost-management):=20harden=20secure=20p?= =?UTF-8?q?roxy=20=E2=80=94=20hardcode=20GET=20method=20and=20declare=20co?= =?UTF-8?q?nfig=20schema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defense-in-depth: hardcode fetch method to 'GET' instead of passing req.method, preventing SSRF amplification if the route registration ever changes from router.get to router.all. Add costManagementProxyBaseUrl to config.d.ts with @visibility backend so the config key is schema-validated and documented. Co-Authored-By: Claude Opus 4.6 --- .../plugins/cost-management-backend/config.d.ts | 9 +++++++++ .../cost-management-backend/src/routes/secureProxy.ts | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/workspaces/cost-management/plugins/cost-management-backend/config.d.ts b/workspaces/cost-management/plugins/cost-management-backend/config.d.ts index e137fc32bf..0e04d6b205 100644 --- a/workspaces/cost-management/plugins/cost-management-backend/config.d.ts +++ b/workspaces/cost-management/plugins/cost-management-backend/config.d.ts @@ -29,4 +29,13 @@ export interface Config { /** @visibility secret */ clientSecret: string; }; + + /** + * Base URL for the Cost Management API proxy target. + * + * @default "https://console.redhat.com/api" + * + * @visibility backend + */ + costManagementProxyBaseUrl?: string; } diff --git a/workspaces/cost-management/plugins/cost-management-backend/src/routes/secureProxy.ts b/workspaces/cost-management/plugins/cost-management-backend/src/routes/secureProxy.ts index e2ce68a88e..5c357a0594 100644 --- a/workspaces/cost-management/plugins/cost-management-backend/src/routes/secureProxy.ts +++ b/workspaces/cost-management/plugins/cost-management-backend/src/routes/secureProxy.ts @@ -331,7 +331,7 @@ export const secureProxy: (options: RouterOptions) => RequestHandler = Accept: acceptHeader, Authorization: `Bearer ${token}`, }, - method: req.method, + method: 'GET', }); const contentType = upstreamResponse.headers.get('content-type') || ''; From d97a600283f2a863669e5a6faeeb5d13184d060e Mon Sep 17 00:00:00 2001 From: gharden Date: Fri, 27 Mar 2026 09:45:04 -0400 Subject: [PATCH 6/8] refactor(cost-management): reduce secureProxy cognitive complexity Extract three helper functions from the secureProxy handler to bring cognitive complexity from 24 down below the SonarQube threshold of 15: - isPathTraversal: path validation guard - parseClientQueryParams: query string parsing with RBAC key stripping - injectRbacFilters: server-side RBAC filter injection Made-with: Cursor --- .../src/routes/secureProxy.ts | 114 +++++++++++------- 1 file changed, 69 insertions(+), 45 deletions(-) diff --git a/workspaces/cost-management/plugins/cost-management-backend/src/routes/secureProxy.ts b/workspaces/cost-management/plugins/cost-management-backend/src/routes/secureProxy.ts index 5c357a0594..b73e1552f7 100644 --- a/workspaces/cost-management/plugins/cost-management-backend/src/routes/secureProxy.ts +++ b/workspaces/cost-management/plugins/cost-management-backend/src/routes/secureProxy.ts @@ -228,6 +228,59 @@ async function resolveCostManagementAccess( ); } +function isPathTraversal(proxyPath: string): boolean { + return /(?:^|\/)\.\.(\/|$)/.test(proxyPath) || proxyPath.startsWith('/'); +} + +/** + * Parses the raw query string, stripping RBAC-controlled keys and + * decoding percent-encoded parameters. Returns null if encoding is malformed. + */ +function parseClientQueryParams( + originalUrl: string, + rbacControlledKeys: Set, +): { key: string; value: string }[] | null { + const rawQuery = originalUrl.split('?')[1] || ''; + const rawParams = rawQuery.split('&').filter(p => p.length > 0); + const result: { key: string; value: string }[] = []; + + for (const param of rawParams) { + const eqIdx = param.indexOf('='); + const rawKey = eqIdx >= 0 ? param.substring(0, eqIdx) : param; + const rawVal = eqIdx >= 0 ? param.substring(eqIdx + 1) : ''; + + try { + const decodedKey = decodeURIComponent(rawKey); + const decodedVal = decodeURIComponent(rawVal); + if (!rbacControlledKeys.has(decodedKey)) { + result.push({ key: decodedKey, value: decodedVal }); + } + } catch { + return null; + } + } + + return result; +} + +/** + * Appends server-side RBAC cluster/project filters to the target URL + * using the appropriate key format for ROS vs Cost Management APIs. + */ +function injectRbacFilters(targetUrl: URL, access: AccessResult): void { + const clusterKey = + access.filterStyle === 'ros' ? 'cluster' : 'filter[exact:cluster]'; + const projectKey = + access.filterStyle === 'ros' ? 'project' : 'filter[exact:project]'; + + access.clusterFilters.forEach(c => + targetUrl.searchParams.append(clusterKey, c), + ); + access.projectFilters.forEach(p => + targetUrl.searchParams.append(projectKey, p), + ); +} + /** * Server-side proxy that keeps the SSO token on the backend and enforces * RBAC before forwarding requests to the Cost Management API. @@ -244,7 +297,7 @@ export const secureProxy: (options: RouterOptions) => RequestHandler = return res.status(400).json({ error: 'Missing proxy path' }); } - if (/(?:^|\/)\.\.(\/|$)/.test(proxyPath) || proxyPath.startsWith('/')) { + if (isPathTraversal(proxyPath)) { return res .status(400) .json({ error: 'Invalid proxy path: traversal not allowed' }); @@ -273,79 +326,50 @@ export const secureProxy: (options: RouterOptions) => RequestHandler = // Express qs parser converts bracket keys like filter[time_scope_value] // into nested objects, losing the flat key format the RHCC API expects. - // Decode each param before matching so percent-encoded variants of - // RBAC-controlled keys (e.g. filter%5Bexact%3Acluster%5D) are also caught. const rbacControlledKeys = new Set( access.filterStyle === 'ros' ? ['cluster', 'project'] : ['filter[exact:cluster]', 'filter[exact:project]'], ); - const rawQuery = req.originalUrl.split('?')[1] || ''; - const rawParams = rawQuery.split('&').filter(p => p.length > 0); - - for (const param of rawParams) { - const eqIdx = param.indexOf('='); - const rawKey = eqIdx >= 0 ? param.substring(0, eqIdx) : param; - const rawVal = eqIdx >= 0 ? param.substring(eqIdx + 1) : ''; - const decodedKey = decodeURIComponent(rawKey); - - if (!rbacControlledKeys.has(decodedKey)) { - targetUrl.searchParams.append(decodedKey, decodeURIComponent(rawVal)); - } + const clientParams = parseClientQueryParams( + req.originalUrl, + rbacControlledKeys, + ); + if (!clientParams) { + return res + .status(400) + .json({ error: 'Malformed percent-encoding in query string' }); } - // Inject server-side RBAC filters (empty arrays = full access, no filter needed) - if (access.clusterFilters.length > 0) { - if (access.filterStyle === 'ros') { - access.clusterFilters.forEach(c => - targetUrl.searchParams.append('cluster', c), - ); - } else { - access.clusterFilters.forEach(c => - targetUrl.searchParams.append('filter[exact:cluster]', c), - ); - } - } - if (access.projectFilters.length > 0) { - if (access.filterStyle === 'ros') { - access.projectFilters.forEach(p => - targetUrl.searchParams.append('project', p), - ); - } else { - access.projectFilters.forEach(p => - targetUrl.searchParams.append('filter[exact:project]', p), - ); - } + for (const { key, value } of clientParams) { + targetUrl.searchParams.append(key, value); } + injectRbacFilters(targetUrl, access); + logger.info( `Proxying ${req.method} to ${targetUrl.pathname}${targetUrl.search}`, ); - const acceptHeader = req.headers.accept || 'application/json'; - const upstreamResponse = await fetch(targetUrl.toString(), { headers: { 'Content-Type': 'application/json', - Accept: acceptHeader, + Accept: req.headers.accept || 'application/json', Authorization: `Bearer ${token}`, }, method: 'GET', }); const contentType = upstreamResponse.headers.get('content-type') || ''; - res.status(upstreamResponse.status); if (contentType.includes('application/json')) { - const data = await upstreamResponse.json(); - return res.json(data); + return res.json(await upstreamResponse.json()); } - const text = await upstreamResponse.text(); res.set('Content-Type', contentType); - return res.send(text); + return res.send(await upstreamResponse.text()); } catch (error) { logger.error('Secure proxy error', error); return res.status(500).json({ error: 'Internal proxy error' }); From d98d4562670401716793110df31eb0f457972d5b Mon Sep 17 00:00:00 2001 From: gharden Date: Fri, 27 Mar 2026 10:20:47 -0400 Subject: [PATCH 7/8] docs(cost-management): update dynamic plugin config with full route bindings Add missing OpenShift route, menu hierarchy, and explain why the proxy.endpoints config was removed (replaced by server-side secure proxy). Made-with: Cursor --- .../cost-management/docs/dynamic-plugin.md | 29 +++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/workspaces/cost-management/docs/dynamic-plugin.md b/workspaces/cost-management/docs/dynamic-plugin.md index 302f8229af..121d8f3a7b 100644 --- a/workspaces/cost-management/docs/dynamic-plugin.md +++ b/workspaces/cost-management/docs/dynamic-plugin.md @@ -38,7 +38,7 @@ The procedure involves the following steps: pluginConfig: dynamicPlugins: frontend: - backstage-community.plugin-cost-management: + red-hat-developer-hub.plugin-cost-management: appIcons: - name: costManagementIconOutlined importName: CostManagementIconOutlined @@ -48,6 +48,22 @@ The procedure involves the following steps: menuItem: icon: costManagementIconOutlined text: Optimizations + - path: /cost-management/openshift + importName: OpenShiftPage + menuItem: + icon: costManagementIconOutlined + text: OpenShift + menuItems: + cost-management/optimizations: + parent: cost-management + priority: 10 + cost-management/openshift: + parent: cost-management + priority: 20 + cost-management: + icon: costManagementIconOutlined + title: Cost management + priority: 100 - package: oci://quay.io/redhat-resource-optimization/dynamic-plugins:latest!red-hat-developer-hub-plugin-cost-management-backend disabled: false pluginConfig: @@ -57,7 +73,10 @@ The procedure involves the following steps: optimizationWorkflowId: 'patch-k8s-resource' ``` - > **Note:** No `proxy` configuration is required. The backend plugin communicates - > with the Red Hat Cost Management API server-side. SSO tokens are obtained - > internally via OAuth2 `client_credentials` grant and never exposed to the - > browser. RBAC filtering is enforced server-side before data is returned. + > **Note:** No `proxy` configuration is required. Previous versions required a + > `proxy.endpoints['/cost-management/v1']` entry that forwarded requests to + > `console.redhat.com` — this has been removed. The backend plugin now + > communicates with the Red Hat Cost Management API server-side via a secure + > proxy. SSO tokens are obtained internally via OAuth2 `client_credentials` + > grant and never exposed to the browser. RBAC filtering is enforced + > server-side before data is returned. See [rbac.md](./rbac.md) for details. From 68d72c57a048ecbe7bed25e76cd933842481d76d Mon Sep 17 00:00:00 2001 From: gharden Date: Sat, 28 Mar 2026 08:32:24 -0400 Subject: [PATCH 8/8] docs(cost-management): align dynamic-plugin.md with verified deployment config Update the ConfigMap example to exactly match the working configuration deployed on ocp-edge73: icon reference name costManagementIcon and dot-separated menuItems keys (cost-management.optimizations). Made-with: Cursor --- .../cost-management/docs/dynamic-plugin.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/workspaces/cost-management/docs/dynamic-plugin.md b/workspaces/cost-management/docs/dynamic-plugin.md index 121d8f3a7b..7e93e90819 100644 --- a/workspaces/cost-management/docs/dynamic-plugin.md +++ b/workspaces/cost-management/docs/dynamic-plugin.md @@ -40,30 +40,30 @@ The procedure involves the following steps: frontend: red-hat-developer-hub.plugin-cost-management: appIcons: - - name: costManagementIconOutlined + - name: costManagementIcon importName: CostManagementIconOutlined dynamicRoutes: - path: /cost-management/optimizations importName: ResourceOptimizationPage menuItem: - icon: costManagementIconOutlined + icon: costManagementIcon text: Optimizations - path: /cost-management/openshift importName: OpenShiftPage menuItem: - icon: costManagementIconOutlined + icon: costManagementIcon text: OpenShift menuItems: - cost-management/optimizations: + cost-management: + icon: costManagementIcon + title: Cost management + priority: 100 + cost-management.optimizations: parent: cost-management priority: 10 - cost-management/openshift: + cost-management.openshift: parent: cost-management priority: 20 - cost-management: - icon: costManagementIconOutlined - title: Cost management - priority: 100 - package: oci://quay.io/redhat-resource-optimization/dynamic-plugins:latest!red-hat-developer-hub-plugin-cost-management-backend disabled: false pluginConfig: