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/docs/dynamic-plugin.md b/workspaces/cost-management/docs/dynamic-plugin.md index 95630784a3..7e93e90819 100644 --- a/workspaces/cost-management/docs/dynamic-plugin.md +++ b/workspaces/cost-management/docs/dynamic-plugin.md @@ -33,32 +33,50 @@ 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: + red-hat-developer-hub.plugin-cost-management: + appIcons: + - name: costManagementIcon + importName: CostManagementIconOutlined + dynamicRoutes: + - path: /cost-management/optimizations + importName: ResourceOptimizationPage + menuItem: + icon: costManagementIcon + text: Optimizations + - path: /cost-management/openshift + importName: OpenShiftPage + menuItem: + icon: costManagementIcon + text: OpenShift + menuItems: + cost-management: + icon: costManagementIcon + title: Cost management + priority: 100 + cost-management.optimizations: + parent: cost-management + priority: 10 + cost-management.openshift: + parent: cost-management + priority: 20 + - 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. 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. 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-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/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/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..b73e1552f7 --- /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); +} + +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 } = options; + const deny = (): AccessResult => ({ + decision: 'DENY', + clusterFilters: [], + projectFilters: [], + filterStyle, + }); + + const pluginDecision = await authorize( + req, + pluginPerms, + permissions, + httpAuth, + ); + if (pluginDecision.result === AuthorizeResult.ALLOW) { + return { + decision: 'ALLOW', + clusterFilters: [], + projectFilters: [], + filterStyle, + }; + } + + let clusterDataMap: Record = {}; + let allProjects: string[] = []; + + const cachedClusters = (await cache.get(cacheKeys.clusterKey)) as + | Record + | undefined; + const cachedProjects = (await cache.get(cacheKeys.projectKey)) as + | string[] + | undefined; + + if (cachedClusters && cachedProjects) { + clusterDataMap = cachedClusters; + allProjects = cachedProjects; + } else { + const result = await fetchData(options); + if (!result) return deny(); + + clusterDataMap = result.clusters; + allProjects = result.projects; + + await Promise.all([ + cache.set(cacheKeys.clusterKey, clusterDataMap, { ttl: CACHE_TTL }), + cache.set(cacheKeys.projectKey, allProjects, { ttl: CACHE_TTL }), + ]); + } + + const { authorizedClusterIds, authorizedClusterProjects } = + await filterAuthorizedClustersAndProjects( + req, + permissions, + httpAuth, + clusterDataMap, + allProjects, + ...(filterStyle === 'cost' ? (['cost'] as const) : []), + ); + + const finalClusters = [ + ...new Set([ + ...authorizedClusterIds, + ...authorizedClusterProjects.map(r => r.cluster), + ]), + ]; + const finalProjects = authorizedClusterProjects.map(r => r.project); + + if (finalClusters.length === 0) return deny(); + + return { + decision: 'ALLOW', + clusterFilters: finalClusters, + projectFilters: finalProjects, + filterStyle, + }; +} + +async function resolveOptimizationsAccess( + req: Request, + options: RouterOptions, +): Promise { + return resolveAccessForSection( + req, + 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(); + + if ((list as any).errors || !list.data) return null; + + const clusters: Record = {}; + list.data.forEach(r => { + if (r.clusterAlias && r.clusterUuid) { + clusters[r.clusterAlias] = r.clusterUuid; + } + }); + const projects = [...new Set(list.data.map(r => r.project))].filter( + (p): p is string => p !== undefined, + ); + + return { clusters, projects }; + }, + ); +} + +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 { clusters, projects }; + }, + ); +} + +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. + * + * 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' }); + } + + if (isPathTraversal(proxyPath)) { + return res + .status(400) + .json({ error: 'Invalid proxy path: traversal not allowed' }); + } + + 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 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. + const rbacControlledKeys = new Set( + access.filterStyle === 'ros' + ? ['cluster', 'project'] + : ['filter[exact:cluster]', 'filter[exact:project]'], + ); + + const clientParams = parseClientQueryParams( + req.originalUrl, + rbacControlledKeys, + ); + if (!clientParams) { + return res + .status(400) + .json({ error: 'Malformed percent-encoding in query string' }); + } + + 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 upstreamResponse = await fetch(targetUrl.toString(), { + headers: { + 'Content-Type': 'application/json', + 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')) { + return res.json(await upstreamResponse.json()); + } + + res.set('Content-Type', contentType); + return res.send(await upstreamResponse.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; - }, - }; - } } 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