diff --git a/workspaces/redhat-resource-optimization/plugins/redhat-resource-optimization-backend/src/routes/access.ts b/workspaces/redhat-resource-optimization/plugins/redhat-resource-optimization-backend/src/routes/access.ts index 9e43d0f192..15088d1b08 100644 --- a/workspaces/redhat-resource-optimization/plugins/redhat-resource-optimization-backend/src/routes/access.ts +++ b/workspaces/redhat-resource-optimization/plugins/redhat-resource-optimization-backend/src/routes/access.ts @@ -18,16 +18,11 @@ import type { RequestHandler } from 'express'; import type { RouterOptions } from '../models/RouterOptions'; import { authorize, - ClusterProjectResult, - filterAuthorizedClusterIds, - filterAuthorizedClusterProjectIds, + filterAuthorizedClustersAndProjects, } from '../util/checkPermissions'; import { rosPluginPermissions } from '@red-hat-developer-hub/plugin-redhat-resource-optimization-common/permissions'; import { getTokenFromApi } from '../util/tokenUtil'; import { AuthorizeResult } from '@backstage/plugin-permission-common'; -import { deepMapKeys } from '@red-hat-developer-hub/plugin-redhat-resource-optimization-common/json-utils'; -import camelCase from 'lodash/camelCase'; -import { RecommendationList } from '@red-hat-developer-hub/plugin-redhat-resource-optimization-common'; export const getAccess: (options: RouterOptions) => RequestHandler = options => async (_, response) => { @@ -74,31 +69,43 @@ export const getAccess: (options: RouterOptions) => RequestHandler = clusterDataMap = clusterMapDataFromCache; allProjects = projectDataFromCache; } else { - // token - const token = await getTokenFromApi(options); - - // hit /recommendation API endpoint - const optimizationResponse = await optimizationApi.getRecommendationList( - { - query: { - limit: -1, - orderHow: 'desc', - orderBy: 'last_reported', - }, - }, - { token }, - ); - - if (optimizationResponse.ok) { - const data = await optimizationResponse.json(); - const camelCaseTransformedResponse = deepMapKeys( - data, - camelCase as (value: string | number) => string, - ) as RecommendationList; + try { + // token + const token = await getTokenFromApi(options); + + // hit /recommendation API endpoint + const optimizationResponse = + await optimizationApi.getRecommendationList( + { + query: { + limit: -1, + orderHow: 'desc', + orderBy: 'last_reported', + }, + }, + { token }, + ); + + // OptimizationsClient already transforms to camelCase when token is provided + const recommendationList = await optimizationResponse.json(); + + // Check if response contains errors + if ((recommendationList as any).errors) { + logger.error( + 'API returned errors:', + (recommendationList as any).errors, + ); + return response.status(500).json({ + decision: AuthorizeResult.DENY, + error: 'API returned errors', + authorizeClusterIds: [], + authorizeProjects: [], + }); + } // retrive cluster data from the API result - if (camelCaseTransformedResponse.data) { - camelCaseTransformedResponse.data.map(recommendation => { + if (recommendationList.data) { + recommendationList.data.map(recommendation => { if (recommendation.clusterAlias && recommendation.clusterUuid) clusterDataMap[recommendation.clusterAlias] = recommendation.clusterUuid; @@ -106,7 +113,7 @@ export const getAccess: (options: RouterOptions) => RequestHandler = allProjects = [ ...new Set( - camelCaseTransformedResponse.data.map( + recommendationList.data.map( recommendation => recommendation.project, ), ), @@ -120,20 +127,30 @@ export const getAccess: (options: RouterOptions) => RequestHandler = ttl: 15 * 60 * 1000, }); } - } else { - throw new Error(optimizationResponse.statusText); + } catch (error) { + logger.error('Error fetching recommendations', error); + + // Return unauthorized response on any error + return response.status(500).json({ + decision: AuthorizeResult.DENY, + error: 'Failed to fetch cluster data', + authorizeClusterIds: [], + authorizeProjects: [], + }); } } - let authorizeClusterIds: string[] = await filterAuthorizedClusterIds( - _, - permissions, - httpAuth, - clusterDataMap, + // RBAC Filtering: Single batch call for both cluster and cluster-project permissions + logger.info( + `Checking permissions for ${ + Object.keys(clusterDataMap).length + } clusters and ${allProjects.length} projects`, ); + logger.info(`Cluster names: ${Object.keys(clusterDataMap).join(', ')}`); + logger.info(`Projects: ${allProjects.join(', ')}`); - const authorizeClustersProjects: ClusterProjectResult[] = - await filterAuthorizedClusterProjectIds( + const { authorizedClusterIds, authorizedClusterProjects } = + await filterAuthorizedClustersAndProjects( _, permissions, httpAuth, @@ -141,18 +158,29 @@ export const getAccess: (options: RouterOptions) => RequestHandler = allProjects, ); - authorizeClusterIds = [ + logger.info( + `Authorization results: ${authorizedClusterIds.length} cluster IDs, ${authorizedClusterProjects.length} cluster-project combinations`, + ); + logger.info(`Authorized cluster IDs: ${authorizedClusterIds.join(', ')}`); + logger.info( + `Authorized cluster-projects: ${authorizedClusterProjects + .map(cp => `${cp.cluster}.${cp.project}`) + .join(', ')}`, + ); + + // Combine cluster IDs from both cluster-level and project-level permissions + const finalAuthorizedClusterIds = [ ...new Set([ - ...authorizeClusterIds, - ...authorizeClustersProjects.map(result => result.cluster), + ...authorizedClusterIds, + ...authorizedClusterProjects.map(result => result.cluster), ]), ]; - const authorizeProjects = authorizeClustersProjects.map( + const authorizeProjects = authorizedClusterProjects.map( result => result.project, ); - if (authorizeClusterIds.length > 0) { + if (finalAuthorizedClusterIds.length > 0) { finalDecision = AuthorizeResult.ALLOW; } else { finalDecision = AuthorizeResult.DENY; @@ -160,7 +188,7 @@ export const getAccess: (options: RouterOptions) => RequestHandler = const body = { decision: finalDecision, - authorizeClusterIds, + authorizeClusterIds: finalAuthorizedClusterIds, authorizeProjects, }; diff --git a/workspaces/redhat-resource-optimization/plugins/redhat-resource-optimization-backend/src/routes/costManagementAccess.ts b/workspaces/redhat-resource-optimization/plugins/redhat-resource-optimization-backend/src/routes/costManagementAccess.ts index 8f5579a273..36b9f46489 100644 --- a/workspaces/redhat-resource-optimization/plugins/redhat-resource-optimization-backend/src/routes/costManagementAccess.ts +++ b/workspaces/redhat-resource-optimization/plugins/redhat-resource-optimization-backend/src/routes/costManagementAccess.ts @@ -18,14 +18,15 @@ import type { RequestHandler } from 'express'; import type { RouterOptions } from '../models/RouterOptions'; import { authorize, - filterAuthorizedClusterIds, + filterAuthorizedClustersAndProjects, } from '../util/checkPermissions'; import { costPluginPermissions } from '@red-hat-developer-hub/plugin-redhat-resource-optimization-common/permissions'; import { AuthorizeResult } from '@backstage/plugin-permission-common'; import { getTokenFromApi } from '../util/tokenUtil'; -// Cache keys for cost management clusters +// Cache keys for cost management clusters and projects const COST_CLUSTERS_CACHE_KEY = 'cost_clusters'; +const COST_PROJECTS_CACHE_KEY = 'cost_projects'; const CACHE_TTL = 15 * 60 * 1000; // 15 minutes export const getCostManagementAccess: ( @@ -56,68 +57,117 @@ export const getCostManagementAccess: ( return response.json(body); } - // RBAC Filtering logic for Cluster using cost.{clusterName} permissions + // RBAC Filtering logic for Cluster & Project using cost.{clusterName} and cost.{clusterName}.{projectName} permissions let clusterDataMap: Record = {}; + let allProjects: string[] = []; // Check the cluster & project data in the cache first 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) { + if (clustersFromCache && projectsFromCache) { clusterDataMap = clustersFromCache; - logger.info(`Using cached data: ${clusterDataMap.length} clusters`); + allProjects = projectsFromCache; + logger.info( + `Using cached data: ${Object.keys(clusterDataMap).length} clusters, ${ + allProjects.length + } projects`, + ); } else { - // Fetch clusters from Cost Management API + // Fetch clusters and projects from Cost Management API try { const token = await getTokenFromApi(options); - const clustersResponse = await costManagementApi.searchOpenShiftClusters( - '', - { token }, - ); + // Fetch clusters and projects in parallel for better performance + 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(); // Extract cluster names from response - clustersData.data?.map( + clustersData.data?.forEach( (cluster: { value: string; cluster_alias: string }) => { - if (cluster.cluster_alias && cluster.value) - logger.info( - `Cluster: ${cluster.cluster_alias} -> ${cluster.value}`, - ); - clusterDataMap[cluster.cluster_alias] = cluster.value; + if (cluster.cluster_alias && cluster.value) { + clusterDataMap[cluster.cluster_alias] = cluster.value; + } }, ); + // Extract unique project names + allProjects = [ + ...new Set( + projectsData.data?.map((project: { value: string }) => project.value), + ), + ].filter(project => project !== undefined) as string[]; + + logger.info( + `Fetched ${Object.keys(clusterDataMap).length} clusters and ${ + allProjects.length + } projects from Cost Management API`, + ); + // Store in cache - await cache.set(COST_CLUSTERS_CACHE_KEY, clusterDataMap, { - ttl: CACHE_TTL, - }); + await Promise.all([ + cache.set(COST_CLUSTERS_CACHE_KEY, clusterDataMap, { + ttl: CACHE_TTL, + }), + cache.set(COST_PROJECTS_CACHE_KEY, allProjects, { + ttl: CACHE_TTL, + }), + ]); } catch (error) { - logger.error(`Failed to fetch clusters from Cost Management API`, error); - throw error; + logger.error('Error fetching cost management data', error); + + // Return unauthorized response on any error + return response.status(500).json({ + decision: AuthorizeResult.DENY, + error: 'Failed to fetch cluster data', + authorizedClusterNames: [], + authorizeProjects: [], + }); } } - // Filter clusters based on cost.{clusterName} permissions - const authorizedClusterNames: string[] = await filterAuthorizedClusterIds( - _, - permissions, - httpAuth, - clusterDataMap, - 'cost', + // RBAC Filtering: Single batch call for both cluster and cluster-project permissions + + const { authorizedClusterIds, authorizedClusterProjects } = + await filterAuthorizedClustersAndProjects( + _, + permissions, + httpAuth, + clusterDataMap, + allProjects, + 'cost', + ); + + // Combine cluster names from both cluster-level and project-level permissions + const finalAuthorizedClusterNames = [ + ...new Set([ + ...authorizedClusterIds, + ...authorizedClusterProjects.map(result => result.cluster), + ]), + ]; + + const authorizeProjects = authorizedClusterProjects.map( + result => result.project, ); // If user has access to at least one cluster, allow access - if (authorizedClusterNames.length > 0) { + if (finalAuthorizedClusterNames.length > 0) { finalDecision = AuthorizeResult.ALLOW; } const body = { decision: finalDecision, - authorizedClusterNames, - authorizeProjects: [], + authorizedClusterNames: finalAuthorizedClusterNames, + authorizeProjects, }; return response.json(body); diff --git a/workspaces/redhat-resource-optimization/plugins/redhat-resource-optimization-backend/src/service/optimizationsService.ts b/workspaces/redhat-resource-optimization/plugins/redhat-resource-optimization-backend/src/service/optimizationsService.ts index ec372da8a3..78dfb7c24b 100644 --- a/workspaces/redhat-resource-optimization/plugins/redhat-resource-optimization-backend/src/service/optimizationsService.ts +++ b/workspaces/redhat-resource-optimization/plugins/redhat-resource-optimization-backend/src/service/optimizationsService.ts @@ -21,7 +21,7 @@ import { } from '@backstage/backend-plugin-api'; import type { OptimizationsApi } from '@red-hat-developer-hub/plugin-redhat-resource-optimization-common/clients'; import { OptimizationsClient } from '@red-hat-developer-hub/plugin-redhat-resource-optimization-common/clients'; -import { DEFAULT_API_BASE_URL } from '../util/constant'; +import { DEFAULT_COST_MANAGEMENT_PROXY_BASE_URL } from '../util/constant'; export const optimizationServiceRef = createServiceRef({ id: 'optimization-client', @@ -32,9 +32,10 @@ export const optimizationServiceRef = createServiceRef({ configApi: coreServices.rootConfig, }, async factory({ configApi }): Promise { + // Base URL without /cost-management/v1 since OptimizationsClient appends it const baseUrl = configApi.getOptionalString('optimizationsBaseUrl') ?? - DEFAULT_API_BASE_URL; + DEFAULT_COST_MANAGEMENT_PROXY_BASE_URL; return new OptimizationsClient({ discoveryApi: { diff --git a/workspaces/redhat-resource-optimization/plugins/redhat-resource-optimization-backend/src/util/checkPermissions.ts b/workspaces/redhat-resource-optimization/plugins/redhat-resource-optimization-backend/src/util/checkPermissions.ts index a5c26c2a0a..c3d87d1a4f 100644 --- a/workspaces/redhat-resource-optimization/plugins/redhat-resource-optimization-backend/src/util/checkPermissions.ts +++ b/workspaces/redhat-resource-optimization/plugins/redhat-resource-optimization-backend/src/util/checkPermissions.ts @@ -29,6 +29,7 @@ import { rosClusterProjectPermission, rosClusterSpecificPermission, costClusterSpecificPermission, + costClusterProjectPermission, } from '@red-hat-developer-hub/plugin-redhat-resource-optimization-common/permissions'; /** Permission type for cluster-level access */ @@ -39,6 +40,23 @@ export interface ClusterProjectResult { project: string; } +export interface CombinedAuthorizationResult { + /** Clusters authorized via cluster-only permissions (all projects allowed) */ + authorizedClusterIds: string[]; + /** Specific cluster-project combinations authorized */ + authorizedClusterProjects: ClusterProjectResult[]; +} + +/** + * Checks if the user has ANY of the given permissions (OR logic). + * Optimized to use a single batch authorization call instead of multiple parallel calls. + * + * @param request - The HTTP request + * @param anyOfPermissions - Array of permissions to check (user needs at least one) + * @param permissionsSvc - The permissions service + * @param httpAuth - The HTTP auth service + * @returns Authorization response with ALLOW if any permission is granted, DENY otherwise + */ export const authorize = async ( request: HttpRequest, anyOfPermissions: BasicPermission[], @@ -47,18 +65,15 @@ export const authorize = async ( ): Promise => { const credentials = await httpAuth.credentials(request); - const decisionResponses: AuthorizePermissionResponse[][] = await Promise.all( - anyOfPermissions.map(permission => - permissionsSvc.authorize([{ permission }], { - credentials, - }), - ), - ); - - const decisions: AuthorizePermissionResponse[] = decisionResponses.map( - d => d[0], - ); + // Single batch call for all permissions + const permissionRequests = anyOfPermissions.map(permission => ({ + permission, + })); + const decisions = await permissionsSvc.authorize(permissionRequests, { + credentials, + }); + // Return ALLOW if any permission is granted const allow = decisions.find(d => d.result === AuthorizeResult.ALLOW); return ( allow || { @@ -68,92 +83,154 @@ export const authorize = async ( }; /** - * Filters cluster IDs based on cluster-specific permissions. + * Combines cluster-only and cluster-project permission checks into a single optimized flow. + * Uses permission hierarchy where project-level access also grants cluster access. + * + * Permission Hierarchy: + * - cost.{cluster} → grants access to cluster + ALL projects in that cluster + * - cost.{cluster}.{project} → grants access to cluster + that specific project + * * @param request - The HTTP request * @param permissionsSvc - The permissions service * @param httpAuth - The HTTP auth service * @param clusterDataMap - Map of clusterName → clusterId - * @param permissionType - 'ros' for ros.{clusterName} or 'cost' for cost.{clusterName} (defaults to 'ros') - * @returns Array of authorized cluster IDs + * @param allProjects - Array of all project names + * @param permissionType - 'ros' or 'cost' permission namespace (defaults to 'ros') + * @returns Object containing both cluster-level and project-level authorizations */ -export const filterAuthorizedClusterIds = async ( +export const filterAuthorizedClustersAndProjects = async ( request: HttpRequest, permissionsSvc: PermissionsService, httpAuth: HttpAuthService, clusterDataMap: Record, + allProjects: string[], permissionType: ClusterPermissionType = 'ros', -): Promise => { +): Promise => { const credentials = await httpAuth.credentials(request); const allClusterNames: string[] = Object.keys(clusterDataMap); + const allClusterIds: string[] = Object.values(clusterDataMap); - // Select the appropriate permission function based on type + // Early exit if no data + if (allClusterNames.length === 0) { + return { + authorizedClusterIds: [], + authorizedClusterProjects: [], + }; + } + + // Select appropriate permission functions based on type const getClusterPermission = permissionType === 'cost' ? costClusterSpecificPermission : rosClusterSpecificPermission; - const specificClusterRequests: AuthorizePermissionRequest[] = - allClusterNames.map(clusterName => ({ - permission: getClusterPermission(clusterName), - })); + const getClusterProjectPermission = + permissionType === 'cost' + ? costClusterProjectPermission + : rosClusterProjectPermission; - const decisions = await permissionsSvc.authorize(specificClusterRequests, { - credentials, - }); + const numClusters = allClusterNames.length; - const authorizeClusterNames = allClusterNames.filter( - (_, idx) => decisions[idx].result === AuthorizeResult.ALLOW, - ); + // Step 1: Check cluster-level permissions first + const clusterPermissionRequests: AuthorizePermissionRequest[] = + allClusterNames.map(clusterName => { + const perm = getClusterPermission(clusterName); + return { permission: perm }; + }); - const authorizedClusterIds = authorizeClusterNames.map( - clusterName => clusterDataMap[clusterName], + const clusterDecisions = await permissionsSvc.authorize( + clusterPermissionRequests, + { + credentials, + }, ); - return permissionType === 'cost' - ? authorizeClusterNames - : authorizedClusterIds; -}; + // Track clusters with and without full access + const clustersWithFullAccess = new Set(); + const clustersWithoutFullAccess: number[] = []; + + for (let i = 0; i < numClusters; i++) { + const clusterName = allClusterNames[i]; + const clusterId = allClusterIds[i]; + const clusterIdentifier = + permissionType === 'cost' ? clusterName : clusterId; + const decision = clusterDecisions[i].result; + + if (decision === AuthorizeResult.ALLOW) { + // User has full cluster access + clustersWithFullAccess.add(clusterIdentifier); + } else { + // No cluster access - will need to check project-level permissions + clustersWithoutFullAccess.push(i); + } + } -export const filterAuthorizedClusterProjectIds = async ( - request: HttpRequest, - permissionsSvc: PermissionsService, - httpAuth: HttpAuthService, - clusterDataMap: Record, - allProjects: string[], -): Promise => { - const credentials = await httpAuth.credentials(request); - const allClusterNames: string[] = Object.keys(clusterDataMap); - const allClusterIds: string[] = Object.values(clusterDataMap); + // Step 2: Check project-level permissions only for clusters without full access + const authorizedClusterProjects: ClusterProjectResult[] = []; + const clustersGrantedViaProjects = new Set(); + + if (clustersWithoutFullAccess.length > 0 && allProjects.length > 0) { + const numProjectChecks = + clustersWithoutFullAccess.length * allProjects.length; + const projectPermissionRequests: AuthorizePermissionRequest[] = new Array( + numProjectChecks, + ); + const projectPermissionMap: ClusterProjectResult[] = new Array( + numProjectChecks, + ); + + // Build requests only for clusters that don't have full access + let idx = 0; + for (const clusterIdx of clustersWithoutFullAccess) { + const clusterName = allClusterNames[clusterIdx]; + const clusterId = allClusterIds[clusterIdx]; + const clusterIdentifier = + permissionType === 'cost' ? clusterName : clusterId; + + for (let j = 0; j < allProjects.length; j++) { + const projectName = allProjects[j]; + const perm = getClusterProjectPermission(clusterName, projectName); + + projectPermissionRequests[idx] = { + permission: perm, + }; + + projectPermissionMap[idx] = { + cluster: clusterIdentifier, + project: projectName, + }; + + idx++; + } + } - const specificClusterProjectRequests: AuthorizePermissionRequest[] = []; - const clusterProjectMap: ClusterProjectResult[] = []; - - for (let i = 0; i < allClusterNames.length; i++) { - for (let j = 0; j < allProjects.length; j++) { - specificClusterProjectRequests.push({ - permission: rosClusterProjectPermission( - allClusterNames[i], - allProjects[j], - ), - }); - - clusterProjectMap.push({ - cluster: allClusterIds[i], - project: allProjects[j], - }); + // Batch check project-level permissions + const projectDecisions = await permissionsSvc.authorize( + projectPermissionRequests, + { credentials }, + ); + + // Process project-level results + for (let i = 0; i < projectDecisions.length; i++) { + const decision = projectDecisions[i].result; + + if (decision === AuthorizeResult.ALLOW) { + const result = projectPermissionMap[i]; + authorizedClusterProjects.push(result); + // Project-level permission also grants cluster access + clustersGrantedViaProjects.add(result.cluster); + } } } - const decisions = await permissionsSvc.authorize( - specificClusterProjectRequests, - { - credentials, - }, - ); - - const finalResult = clusterProjectMap.filter( - (_, idx) => decisions[idx].result === AuthorizeResult.ALLOW, - ); + // Step 3: Combine clusters from both full access and project-level grants + const authorizedClusterIds = [ + ...clustersWithFullAccess, + ...clustersGrantedViaProjects, + ]; - return finalResult; + return { + authorizedClusterIds, + authorizedClusterProjects, + }; }; diff --git a/workspaces/redhat-resource-optimization/plugins/redhat-resource-optimization-backend/src/util/constant.ts b/workspaces/redhat-resource-optimization/plugins/redhat-resource-optimization-backend/src/util/constant.ts index 86fd54319c..5fb2e24506 100644 --- a/workspaces/redhat-resource-optimization/plugins/redhat-resource-optimization-backend/src/util/constant.ts +++ b/workspaces/redhat-resource-optimization/plugins/redhat-resource-optimization-backend/src/util/constant.ts @@ -15,8 +15,6 @@ */ export const DEFAULT_SSO_BASE_URL = 'https://sso.redhat.com'; -export const DEFAULT_API_BASE_URL = - 'https://console.redhat.com/api/cost-management/v1'; // Base URL without the /cost-management/v1 path since the client appends it export const DEFAULT_COST_MANAGEMENT_PROXY_BASE_URL = diff --git a/workspaces/redhat-resource-optimization/plugins/redhat-resource-optimization-backend/src/util/tokenUtil.ts b/workspaces/redhat-resource-optimization/plugins/redhat-resource-optimization-backend/src/util/tokenUtil.ts index 49deb83747..aea2840efa 100644 --- a/workspaces/redhat-resource-optimization/plugins/redhat-resource-optimization-backend/src/util/tokenUtil.ts +++ b/workspaces/redhat-resource-optimization/plugins/redhat-resource-optimization-backend/src/util/tokenUtil.ts @@ -18,8 +18,36 @@ import assert from 'assert'; import { RouterOptions } from '../models/RouterOptions'; import { DEFAULT_SSO_BASE_URL } from './constant'; +// Cache key for token storage +const TOKEN_CACHE_KEY = 'sso_access_token'; + export const getTokenFromApi = async (options: RouterOptions) => { - const { logger, config } = options; + const { logger, config, cache } = options; + + const now = Date.now(); + + // Try to get cached token from cache service + const cachedToken = (await cache.get(TOKEN_CACHE_KEY)) as + | { token: string; expiresAt: number } + | undefined; + + // Debug logging + if (cachedToken) { + const timeUntilExpiry = cachedToken.expiresAt - now; + const timeUntilExpirySeconds = Math.floor(timeUntilExpiry / 1000); + logger.info( + `Cache check: Token expires in ${timeUntilExpirySeconds}s, needs >60s to be valid`, + ); + } else { + logger.info('Cache check: No cached token exists'); + } + + // Return cached token if still valid (with 60s buffer) + if (cachedToken && cachedToken.expiresAt > now + 60000) { + logger.info('Using cached access token'); + return cachedToken.token; + } + let accessToken = ''; assert(typeof config !== 'undefined', 'Config is undefined'); @@ -53,8 +81,24 @@ export const getTokenFromApi = async (options: RouterOptions) => { }); if (rhSsoResponse.ok) { - const { access_token } = await rhSsoResponse.json(); + const { access_token, expires_in } = await rhSsoResponse.json(); accessToken = access_token; + + const expiresAt = Date.now() + expires_in * 1000; + + // Cache token with expiry using cache service + await cache.set( + TOKEN_CACHE_KEY, + { + token: accessToken, + expiresAt, + }, + { + ttl: expires_in * 1000, // TTL in milliseconds + }, + ); + + logger.info(`Token cached, expires in ${expires_in} seconds`); } else { throw new Error(rhSsoResponse.statusText); } diff --git a/workspaces/redhat-resource-optimization/plugins/redhat-resource-optimization-common/report-clients.api.md b/workspaces/redhat-resource-optimization/plugins/redhat-resource-optimization-common/report-clients.api.md index 240639aa55..ec75f5519c 100644 --- a/workspaces/redhat-resource-optimization/plugins/redhat-resource-optimization-common/report-clients.api.md +++ b/workspaces/redhat-resource-optimization/plugins/redhat-resource-optimization-common/report-clients.api.md @@ -113,6 +113,7 @@ export interface CostManagementSlimApi { search?: string, options?: { token?: string; + limit?: number; }, ): Promise< TypedResponse<{ @@ -139,6 +140,7 @@ export interface CostManagementSlimApi { search?: string, options?: { token?: string; + limit?: number; }, ): Promise< TypedResponse<{ @@ -187,6 +189,7 @@ export class CostManagementSlimClient implements CostManagementSlimApi { search?: string, options?: { token?: string; + limit?: number; }, ): Promise< TypedResponse<{ @@ -207,10 +210,13 @@ export class CostManagementSlimClient implements CostManagementSlimApi { links?: any; }> >; + // Warning: (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a valid parameter name: The identifier cannot non-word characters + // Warning: (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a valid parameter name: The identifier cannot non-word characters searchOpenShiftProjects( search?: string, options?: { token?: string; + limit?: number; }, ): Promise< TypedResponse<{ @@ -392,11 +398,13 @@ export class OptimizationsClient implements OptimizationsApi { getRecommendationById( request: GetRecommendationByIdRequest, ): Promise>; + // Warning: (ae-forgotten-export) The symbol "RequestOptions" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "RecommendationList" needs to be exported by the entry point index.d.ts // // (undocumented) getRecommendationList( request: GetRecommendationListRequest, + options?: RequestOptions, ): Promise>; } diff --git a/workspaces/redhat-resource-optimization/plugins/redhat-resource-optimization-common/report-permissions.api.md b/workspaces/redhat-resource-optimization/plugins/redhat-resource-optimization-common/report-permissions.api.md index 8d6177ffbc..86275a72b6 100644 --- a/workspaces/redhat-resource-optimization/plugins/redhat-resource-optimization-common/report-permissions.api.md +++ b/workspaces/redhat-resource-optimization/plugins/redhat-resource-optimization-common/report-permissions.api.md @@ -5,6 +5,12 @@ ```ts import { BasicPermission } from '@backstage/plugin-permission-common'; +// @public (undocumented) +export const costClusterProjectPermission: ( + clusterName: string, + projectName: string, +) => BasicPermission; + // @public (undocumented) export const costClusterSpecificPermission: ( clusterName: string, diff --git a/workspaces/redhat-resource-optimization/plugins/redhat-resource-optimization-common/report.api.md b/workspaces/redhat-resource-optimization/plugins/redhat-resource-optimization-common/report.api.md index 6360d2c8b2..5ca7deb099 100644 --- a/workspaces/redhat-resource-optimization/plugins/redhat-resource-optimization-common/report.api.md +++ b/workspaces/redhat-resource-optimization/plugins/redhat-resource-optimization-common/report.api.md @@ -111,6 +111,7 @@ export interface CostManagementSlimApi { search?: string, options?: { token?: string; + limit?: number; }, ): Promise< TypedResponse<{ @@ -137,6 +138,7 @@ export interface CostManagementSlimApi { search?: string, options?: { token?: string; + limit?: number; }, ): Promise< TypedResponse<{ @@ -185,6 +187,7 @@ export class CostManagementSlimClient implements CostManagementSlimApi { search?: string, options?: { token?: string; + limit?: number; }, ): Promise< TypedResponse<{ @@ -205,10 +208,13 @@ export class CostManagementSlimClient implements CostManagementSlimApi { links?: any; }> >; + // Warning: (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a valid parameter name: The identifier cannot non-word characters + // Warning: (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a valid parameter name: The identifier cannot non-word characters searchOpenShiftProjects( search?: string, options?: { token?: string; + limit?: number; }, ): Promise< TypedResponse<{ @@ -721,6 +727,7 @@ export class OptimizationsClient implements OptimizationsApi { // (undocumented) getRecommendationList( request: GetRecommendationListRequest, + options?: RequestOptions, ): Promise>; } diff --git a/workspaces/redhat-resource-optimization/plugins/redhat-resource-optimization-common/src/clients/cost-management/CostManagementSlimApi.ts b/workspaces/redhat-resource-optimization/plugins/redhat-resource-optimization-common/src/clients/cost-management/CostManagementSlimApi.ts index 7e15c3e452..b83abf13d5 100644 --- a/workspaces/redhat-resource-optimization/plugins/redhat-resource-optimization-common/src/clients/cost-management/CostManagementSlimApi.ts +++ b/workspaces/redhat-resource-optimization/plugins/redhat-resource-optimization-common/src/clients/cost-management/CostManagementSlimApi.ts @@ -31,13 +31,13 @@ export interface CostManagementSlimApi { ): Promise; searchOpenShiftProjects( search?: string, - options?: { token?: string }, + options?: { token?: string; limit?: number }, ): Promise< TypedResponse<{ data: Array<{ value: string }>; meta?: any; links?: any }> >; searchOpenShiftClusters( search?: string, - options?: { token?: string }, + options?: { token?: string; limit?: number }, ): Promise< TypedResponse<{ data: Array<{ value: string; cluster_alias: string }>; diff --git a/workspaces/redhat-resource-optimization/plugins/redhat-resource-optimization-common/src/clients/cost-management/CostManagementSlimClient.ts b/workspaces/redhat-resource-optimization/plugins/redhat-resource-optimization-common/src/clients/cost-management/CostManagementSlimClient.ts index 0e705f7afd..12a84b94e5 100644 --- a/workspaces/redhat-resource-optimization/plugins/redhat-resource-optimization-common/src/clients/cost-management/CostManagementSlimClient.ts +++ b/workspaces/redhat-resource-optimization/plugins/redhat-resource-optimization-common/src/clients/cost-management/CostManagementSlimClient.ts @@ -36,6 +36,8 @@ 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; @@ -45,18 +47,8 @@ export class CostManagementSlimClient implements CostManagementSlimApi { public async getCostManagementReport( request: GetCostManagementRequest, ): Promise> { - // Get access permission and authorized clusters - const accessAPIResponse = await this.getAccess(); - - if (accessAPIResponse.decision === AuthorizeResult.DENY) { - throw new UnauthorizedError(); - } - - // Get or refresh token - if (!this.token) { - const { accessToken } = await this.getNewToken(); - this.token = accessToken; - } + // Ensure access and token + await this.ensureAccessAndToken(); // Get the proxy base URL for cost-management API const baseUrl = await this.discoveryApi.getBaseUrl('proxy'); @@ -65,16 +57,8 @@ export class CostManagementSlimClient implements CostManagementSlimApi { // Build query params from the original request const queryParams = this.buildCostManagementQueryParams(request); - // Add cluster filters based on authorized clusters - // Empty array means full access (no filtering needed) - const authorizedClusters = accessAPIResponse.authorizedClusterNames || []; - if (authorizedClusters.length > 0) { - // Add each authorized cluster as a filter parameter - // Cost Management API accepts multiple filter[exact:cluster] params - authorizedClusters.forEach(clusterName => { - queryParams.append('filter[exact:cluster]', clusterName); - }); - } + // Add RBAC filters + await this.appendRBACFilters(queryParams); const queryString = queryParams.toString(); const queryPart = queryString ? `?${queryString}` : ''; @@ -86,18 +70,8 @@ export class CostManagementSlimClient implements CostManagementSlimApi { public async downloadCostManagementReport( request: DownloadCostManagementRequest, ): Promise { - // Get access permission and authorized clusters - const accessAPIResponse = await this.getAccess(); - - if (accessAPIResponse.decision === AuthorizeResult.DENY) { - throw new UnauthorizedError(); - } - - // Get or refresh token - if (!this.token) { - const { accessToken } = await this.getNewToken(); - this.token = accessToken; - } + // Ensure access and token + await this.ensureAccessAndToken(); // Get the proxy base URL for cost-management API const baseUrl = await this.discoveryApi.getBaseUrl('proxy'); @@ -115,14 +89,8 @@ export class CostManagementSlimClient implements CostManagementSlimApi { const queryParams = this.buildCostManagementQueryParams(downloadRequest); - // Add cluster filters based on authorized clusters - // Empty array means full access (no filtering needed) - const authorizedClusters = accessAPIResponse.authorizedClusterNames || []; - if (authorizedClusters.length > 0) { - authorizedClusters.forEach(clusterName => { - queryParams.append('filter[exact:cluster]', clusterName); - }); - } + // Add RBAC filters + await this.appendRBACFilters(queryParams); const queryString = queryParams.toString(); const queryPart = queryString ? `?${queryString}` : ''; @@ -416,40 +384,25 @@ export class CostManagementSlimClient implements CostManagementSlimApi { /** * Search OpenShift projects * @param search - Search term to filter projects + * @param options - Optional request options including token and limit + * @param options.token - Bearer token for authentication (backend use) + * @param options.limit - Maximum number of results to return (default: 10, use large number for all) */ public async searchOpenShiftProjects( search: string = '', - options?: { token?: string }, + options?: { token?: string; limit?: number }, ): Promise< TypedResponse<{ data: Array<{ value: string }>; meta?: any; links?: any }> > { - const baseUrl = await this.discoveryApi.getBaseUrl('proxy'); - const searchParam = search ? `?search=${encodeURIComponent(search)}` : ''; - const url = `${baseUrl}/cost-management/v1/resource-types/openshift-projects/${searchParam}`; + const url = await this.buildResourceTypeUrl( + 'openshift-projects', + search, + options?.limit, + ); // If token provided externally (backend use), use it directly if (options?.token) { - const response = await this.fetchApi.fetch(url, { - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${options.token}`, - }, - method: 'GET', - }); - - if (!response.ok) { - throw new Error(response.statusText); - } - - return { - ok: response.ok, - status: response.status, - json: async () => response.json(), - } as TypedResponse<{ - data: Array<{ value: string }>; - meta?: any; - links?: any; - }>; + return await this.fetchWithExternalToken(url, options.token); } // If no external token, use internal token retrieval @@ -462,7 +415,7 @@ export class CostManagementSlimClient implements CostManagementSlimApi { */ public async searchOpenShiftClusters( search: string = '', - options?: { token?: string }, + options?: { token?: string; limit?: number }, ): Promise< TypedResponse<{ data: Array<{ value: string; cluster_alias: string }>; @@ -470,37 +423,18 @@ export class CostManagementSlimClient implements CostManagementSlimApi { links?: any; }> > { - const baseUrl = await this.discoveryApi.getBaseUrl('proxy'); - const searchParam = search ? `?search=${encodeURIComponent(search)}` : ''; - const url = `${baseUrl}/cost-management/v1/resource-types/openshift-clusters/${searchParam}`; + const url = await this.buildResourceTypeUrl( + 'openshift-clusters', + search, + options?.limit, + ); // If token provided externally (backend use), use it directly if (options?.token) { - const response = await this.fetchApi.fetch(url, { - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${options.token}`, - }, - method: 'GET', - }); - - if (!response.ok) { - throw new Error(response.statusText); - } - - return { - ok: response.ok, - status: response.status, - json: async () => response.json(), - } as TypedResponse<{ - data: Array<{ value: string; cluster_alias: string }>; - meta?: any; - links?: any; - }>; + return await this.fetchWithExternalToken(url, options.token); } // If no external token, use internal token retrieval - return await this.fetchResourceType(url); } @@ -528,21 +462,13 @@ export class CostManagementSlimClient implements CostManagementSlimApi { public async getOpenShiftTags( timeScopeValue: number = -1, ): Promise> { - const baseUrl = await this.discoveryApi.getBaseUrl('proxy'); - const url = `${baseUrl}/cost-management/v1/tags/openshift/?filter[time_scope_value]=${timeScopeValue}&key_only=true&limit=1000`; - - // Get access permission - const accessAPIResponse = await this.getAccess(); - - if (accessAPIResponse.decision === AuthorizeResult.DENY) { - throw new UnauthorizedError(); - } + // Ensure access and token + await this.ensureAccessAndToken(); - // Get or refresh token - if (!this.token) { - const { accessToken } = await this.getNewToken(); - this.token = accessToken; - } + 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`, + ); return await this.fetchWithTokenAndRetry<{ data: string[]; @@ -567,23 +493,15 @@ export class CostManagementSlimClient implements CostManagementSlimApi { links?: any; }> > { - const baseUrl = await this.discoveryApi.getBaseUrl('proxy'); - const url = `${baseUrl}/cost-management/v1/tags/openshift/?filter[key]=${encodeURIComponent( - tagKey, - )}&filter[time_scope_value]=${timeScopeValue}`; - - // Get access permission - const accessAPIResponse = await this.getAccess(); + // Ensure access and token + await this.ensureAccessAndToken(); - if (accessAPIResponse.decision === AuthorizeResult.DENY) { - throw new UnauthorizedError(); - } - - // Get or refresh token - if (!this.token) { - const { accessToken } = await this.getNewToken(); - this.token = accessToken; - } + 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}`, + ); return await this.fetchWithTokenAndRetry<{ data: Array<{ key: string; values: string[]; enabled: boolean }>; @@ -612,6 +530,117 @@ export class CostManagementSlimClient implements CostManagementSlimApi { }>(url); } + /** + * 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 + */ + private async buildResourceTypeUrl( + resourceType: string, + search?: string, + limit?: number, + ): Promise { + const baseUrl = await this.discoveryApi.getBaseUrl('proxy'); + + const params = new URLSearchParams(); + if (search) { + params.append('search', search); + } + if (limit !== undefined) { + params.append('limit', String(limit)); + } + const queryString = params.toString(); + const queryPart = queryString ? `?${queryString}` : ''; + + return `${baseUrl}/cost-management/v1/resource-types/${resourceType}/${queryPart}`; + } + + /** + * Fetches with externally provided token (for backend use) + */ + private async fetchWithExternalToken( + url: string, + token: string, + ): Promise> { + const response = await this.fetchApi.fetch(url, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + method: 'GET', + }); + + if (!response.ok) { + throw new Error(response.statusText); + } + + return { + ok: response.ok, + status: response.status, + json: async () => response.json(), + } as TypedResponse; + } + /** * Fetches a URL with token authentication, handles 401 errors by refreshing token and retrying * @param url - The URL to fetch @@ -656,12 +685,30 @@ export class CostManagementSlimClient implements CostManagementSlimApi { }; } - private async getAccess(): Promise { + 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; } diff --git a/workspaces/redhat-resource-optimization/plugins/redhat-resource-optimization-common/src/clients/optimizations/OptimizationsClient.ts b/workspaces/redhat-resource-optimization/plugins/redhat-resource-optimization-common/src/clients/optimizations/OptimizationsClient.ts index 04086eee4f..2fc05502cd 100644 --- a/workspaces/redhat-resource-optimization/plugins/redhat-resource-optimization-common/src/clients/optimizations/OptimizationsClient.ts +++ b/workspaces/redhat-resource-optimization/plugins/redhat-resource-optimization-common/src/clients/optimizations/OptimizationsClient.ts @@ -118,12 +118,34 @@ export class OptimizationsClient implements OptimizationsApi { public async getRecommendationList( request: GetRecommendationListRequest, + options?: RequestOptions, ): Promise> { const snakeCaseTransformedRequest = deepMapKeys( request, 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; + }, + }; + } + + // Frontend use case - use fetchWithToken which includes access check const response = await this.fetchWithToken( this.defaultClient.getRecommendationList, snakeCaseTransformedRequest, diff --git a/workspaces/redhat-resource-optimization/plugins/redhat-resource-optimization-common/src/permissions.ts b/workspaces/redhat-resource-optimization/plugins/redhat-resource-optimization-common/src/permissions.ts index 9e4caea865..aef26815f7 100644 --- a/workspaces/redhat-resource-optimization/plugins/redhat-resource-optimization-common/src/permissions.ts +++ b/workspaces/redhat-resource-optimization/plugins/redhat-resource-optimization-common/src/permissions.ts @@ -52,6 +52,16 @@ export const costClusterSpecificPermission = (clusterName: string) => attributes: { action: 'read' }, }); +/** @public */ +export const costClusterProjectPermission = ( + clusterName: string, + projectName: string, +) => + createPermission({ + name: `cost.${clusterName}.${projectName}`, + attributes: { action: 'read' }, + }); + /** @public */ export const costPluginPermissions = [costPluginReadPermission];