Skip to content

Commit a173cb6

Browse files
authored
Rhineng 22952 Added cost.cluster.project RBAC permission (#2102)
* RHINENG-22952 - added RBAC permission check for cost.cluster.project permission * chore: added limit to the search api calls * chore: code cleanup and api reports * chore: cleanup for checkPermission.ts and fixes error in /access endpoint * chore: fixed code duplication * chore: fixed api reports
1 parent 8d74b8c commit a173cb6

13 files changed

Lines changed: 576 additions & 278 deletions

File tree

workspaces/redhat-resource-optimization/plugins/redhat-resource-optimization-backend/src/routes/access.ts

Lines changed: 73 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,11 @@ import type { RequestHandler } from 'express';
1818
import type { RouterOptions } from '../models/RouterOptions';
1919
import {
2020
authorize,
21-
ClusterProjectResult,
22-
filterAuthorizedClusterIds,
23-
filterAuthorizedClusterProjectIds,
21+
filterAuthorizedClustersAndProjects,
2422
} from '../util/checkPermissions';
2523
import { rosPluginPermissions } from '@red-hat-developer-hub/plugin-redhat-resource-optimization-common/permissions';
2624
import { getTokenFromApi } from '../util/tokenUtil';
2725
import { AuthorizeResult } from '@backstage/plugin-permission-common';
28-
import { deepMapKeys } from '@red-hat-developer-hub/plugin-redhat-resource-optimization-common/json-utils';
29-
import camelCase from 'lodash/camelCase';
30-
import { RecommendationList } from '@red-hat-developer-hub/plugin-redhat-resource-optimization-common';
3126

3227
export const getAccess: (options: RouterOptions) => RequestHandler =
3328
options => async (_, response) => {
@@ -74,39 +69,51 @@ export const getAccess: (options: RouterOptions) => RequestHandler =
7469
clusterDataMap = clusterMapDataFromCache;
7570
allProjects = projectDataFromCache;
7671
} else {
77-
// token
78-
const token = await getTokenFromApi(options);
79-
80-
// hit /recommendation API endpoint
81-
const optimizationResponse = await optimizationApi.getRecommendationList(
82-
{
83-
query: {
84-
limit: -1,
85-
orderHow: 'desc',
86-
orderBy: 'last_reported',
87-
},
88-
},
89-
{ token },
90-
);
91-
92-
if (optimizationResponse.ok) {
93-
const data = await optimizationResponse.json();
94-
const camelCaseTransformedResponse = deepMapKeys(
95-
data,
96-
camelCase as (value: string | number) => string,
97-
) as RecommendationList;
72+
try {
73+
// token
74+
const token = await getTokenFromApi(options);
75+
76+
// hit /recommendation API endpoint
77+
const optimizationResponse =
78+
await optimizationApi.getRecommendationList(
79+
{
80+
query: {
81+
limit: -1,
82+
orderHow: 'desc',
83+
orderBy: 'last_reported',
84+
},
85+
},
86+
{ token },
87+
);
88+
89+
// OptimizationsClient already transforms to camelCase when token is provided
90+
const recommendationList = await optimizationResponse.json();
91+
92+
// Check if response contains errors
93+
if ((recommendationList as any).errors) {
94+
logger.error(
95+
'API returned errors:',
96+
(recommendationList as any).errors,
97+
);
98+
return response.status(500).json({
99+
decision: AuthorizeResult.DENY,
100+
error: 'API returned errors',
101+
authorizeClusterIds: [],
102+
authorizeProjects: [],
103+
});
104+
}
98105

99106
// retrive cluster data from the API result
100-
if (camelCaseTransformedResponse.data) {
101-
camelCaseTransformedResponse.data.map(recommendation => {
107+
if (recommendationList.data) {
108+
recommendationList.data.map(recommendation => {
102109
if (recommendation.clusterAlias && recommendation.clusterUuid)
103110
clusterDataMap[recommendation.clusterAlias] =
104111
recommendation.clusterUuid;
105112
});
106113

107114
allProjects = [
108115
...new Set(
109-
camelCaseTransformedResponse.data.map(
116+
recommendationList.data.map(
110117
recommendation => recommendation.project,
111118
),
112119
),
@@ -120,47 +127,68 @@ export const getAccess: (options: RouterOptions) => RequestHandler =
120127
ttl: 15 * 60 * 1000,
121128
});
122129
}
123-
} else {
124-
throw new Error(optimizationResponse.statusText);
130+
} catch (error) {
131+
logger.error('Error fetching recommendations', error);
132+
133+
// Return unauthorized response on any error
134+
return response.status(500).json({
135+
decision: AuthorizeResult.DENY,
136+
error: 'Failed to fetch cluster data',
137+
authorizeClusterIds: [],
138+
authorizeProjects: [],
139+
});
125140
}
126141
}
127142

128-
let authorizeClusterIds: string[] = await filterAuthorizedClusterIds(
129-
_,
130-
permissions,
131-
httpAuth,
132-
clusterDataMap,
143+
// RBAC Filtering: Single batch call for both cluster and cluster-project permissions
144+
logger.info(
145+
`Checking permissions for ${
146+
Object.keys(clusterDataMap).length
147+
} clusters and ${allProjects.length} projects`,
133148
);
149+
logger.info(`Cluster names: ${Object.keys(clusterDataMap).join(', ')}`);
150+
logger.info(`Projects: ${allProjects.join(', ')}`);
134151

135-
const authorizeClustersProjects: ClusterProjectResult[] =
136-
await filterAuthorizedClusterProjectIds(
152+
const { authorizedClusterIds, authorizedClusterProjects } =
153+
await filterAuthorizedClustersAndProjects(
137154
_,
138155
permissions,
139156
httpAuth,
140157
clusterDataMap,
141158
allProjects,
142159
);
143160

144-
authorizeClusterIds = [
161+
logger.info(
162+
`Authorization results: ${authorizedClusterIds.length} cluster IDs, ${authorizedClusterProjects.length} cluster-project combinations`,
163+
);
164+
logger.info(`Authorized cluster IDs: ${authorizedClusterIds.join(', ')}`);
165+
logger.info(
166+
`Authorized cluster-projects: ${authorizedClusterProjects
167+
.map(cp => `${cp.cluster}.${cp.project}`)
168+
.join(', ')}`,
169+
);
170+
171+
// Combine cluster IDs from both cluster-level and project-level permissions
172+
const finalAuthorizedClusterIds = [
145173
...new Set([
146-
...authorizeClusterIds,
147-
...authorizeClustersProjects.map(result => result.cluster),
174+
...authorizedClusterIds,
175+
...authorizedClusterProjects.map(result => result.cluster),
148176
]),
149177
];
150178

151-
const authorizeProjects = authorizeClustersProjects.map(
179+
const authorizeProjects = authorizedClusterProjects.map(
152180
result => result.project,
153181
);
154182

155-
if (authorizeClusterIds.length > 0) {
183+
if (finalAuthorizedClusterIds.length > 0) {
156184
finalDecision = AuthorizeResult.ALLOW;
157185
} else {
158186
finalDecision = AuthorizeResult.DENY;
159187
}
160188

161189
const body = {
162190
decision: finalDecision,
163-
authorizeClusterIds,
191+
authorizeClusterIds: finalAuthorizedClusterIds,
164192
authorizeProjects,
165193
};
166194

workspaces/redhat-resource-optimization/plugins/redhat-resource-optimization-backend/src/routes/costManagementAccess.ts

Lines changed: 81 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,15 @@ import type { RequestHandler } from 'express';
1818
import type { RouterOptions } from '../models/RouterOptions';
1919
import {
2020
authorize,
21-
filterAuthorizedClusterIds,
21+
filterAuthorizedClustersAndProjects,
2222
} from '../util/checkPermissions';
2323
import { costPluginPermissions } from '@red-hat-developer-hub/plugin-redhat-resource-optimization-common/permissions';
2424
import { AuthorizeResult } from '@backstage/plugin-permission-common';
2525
import { getTokenFromApi } from '../util/tokenUtil';
2626

27-
// Cache keys for cost management clusters
27+
// Cache keys for cost management clusters and projects
2828
const COST_CLUSTERS_CACHE_KEY = 'cost_clusters';
29+
const COST_PROJECTS_CACHE_KEY = 'cost_projects';
2930
const CACHE_TTL = 15 * 60 * 1000; // 15 minutes
3031

3132
export const getCostManagementAccess: (
@@ -56,68 +57,117 @@ export const getCostManagementAccess: (
5657
return response.json(body);
5758
}
5859

59-
// RBAC Filtering logic for Cluster using cost.{clusterName} permissions
60+
// RBAC Filtering logic for Cluster & Project using cost.{clusterName} and cost.{clusterName}.{projectName} permissions
6061
let clusterDataMap: Record<string, string> = {};
62+
let allProjects: string[] = [];
6163

6264
// Check the cluster & project data in the cache first
6365
const clustersFromCache = (await cache.get(COST_CLUSTERS_CACHE_KEY)) as
6466
| Record<string, string>
6567
| undefined;
68+
const projectsFromCache = (await cache.get(COST_PROJECTS_CACHE_KEY)) as
69+
| string[]
70+
| undefined;
6671

67-
if (clustersFromCache) {
72+
if (clustersFromCache && projectsFromCache) {
6873
clusterDataMap = clustersFromCache;
69-
logger.info(`Using cached data: ${clusterDataMap.length} clusters`);
74+
allProjects = projectsFromCache;
75+
logger.info(
76+
`Using cached data: ${Object.keys(clusterDataMap).length} clusters, ${
77+
allProjects.length
78+
} projects`,
79+
);
7080
} else {
71-
// Fetch clusters from Cost Management API
81+
// Fetch clusters and projects from Cost Management API
7282
try {
7383
const token = await getTokenFromApi(options);
7484

75-
const clustersResponse = await costManagementApi.searchOpenShiftClusters(
76-
'',
77-
{ token },
78-
);
85+
// Fetch clusters and projects in parallel for better performance
86+
const [clustersResponse, projectsResponse] = await Promise.all([
87+
costManagementApi.searchOpenShiftClusters('', { token, limit: 1000 }),
88+
costManagementApi.searchOpenShiftProjects('', { token, limit: 1000 }),
89+
]);
7990

8091
const clustersData = await clustersResponse.json();
92+
const projectsData = await projectsResponse.json();
8193

8294
// Extract cluster names from response
83-
clustersData.data?.map(
95+
clustersData.data?.forEach(
8496
(cluster: { value: string; cluster_alias: string }) => {
85-
if (cluster.cluster_alias && cluster.value)
86-
logger.info(
87-
`Cluster: ${cluster.cluster_alias} -> ${cluster.value}`,
88-
);
89-
clusterDataMap[cluster.cluster_alias] = cluster.value;
97+
if (cluster.cluster_alias && cluster.value) {
98+
clusterDataMap[cluster.cluster_alias] = cluster.value;
99+
}
90100
},
91101
);
92102

103+
// Extract unique project names
104+
allProjects = [
105+
...new Set(
106+
projectsData.data?.map((project: { value: string }) => project.value),
107+
),
108+
].filter(project => project !== undefined) as string[];
109+
110+
logger.info(
111+
`Fetched ${Object.keys(clusterDataMap).length} clusters and ${
112+
allProjects.length
113+
} projects from Cost Management API`,
114+
);
115+
93116
// Store in cache
94-
await cache.set(COST_CLUSTERS_CACHE_KEY, clusterDataMap, {
95-
ttl: CACHE_TTL,
96-
});
117+
await Promise.all([
118+
cache.set(COST_CLUSTERS_CACHE_KEY, clusterDataMap, {
119+
ttl: CACHE_TTL,
120+
}),
121+
cache.set(COST_PROJECTS_CACHE_KEY, allProjects, {
122+
ttl: CACHE_TTL,
123+
}),
124+
]);
97125
} catch (error) {
98-
logger.error(`Failed to fetch clusters from Cost Management API`, error);
99-
throw error;
126+
logger.error('Error fetching cost management data', error);
127+
128+
// Return unauthorized response on any error
129+
return response.status(500).json({
130+
decision: AuthorizeResult.DENY,
131+
error: 'Failed to fetch cluster data',
132+
authorizedClusterNames: [],
133+
authorizeProjects: [],
134+
});
100135
}
101136
}
102137

103-
// Filter clusters based on cost.{clusterName} permissions
104-
const authorizedClusterNames: string[] = await filterAuthorizedClusterIds(
105-
_,
106-
permissions,
107-
httpAuth,
108-
clusterDataMap,
109-
'cost',
138+
// RBAC Filtering: Single batch call for both cluster and cluster-project permissions
139+
140+
const { authorizedClusterIds, authorizedClusterProjects } =
141+
await filterAuthorizedClustersAndProjects(
142+
_,
143+
permissions,
144+
httpAuth,
145+
clusterDataMap,
146+
allProjects,
147+
'cost',
148+
);
149+
150+
// Combine cluster names from both cluster-level and project-level permissions
151+
const finalAuthorizedClusterNames = [
152+
...new Set([
153+
...authorizedClusterIds,
154+
...authorizedClusterProjects.map(result => result.cluster),
155+
]),
156+
];
157+
158+
const authorizeProjects = authorizedClusterProjects.map(
159+
result => result.project,
110160
);
111161

112162
// If user has access to at least one cluster, allow access
113-
if (authorizedClusterNames.length > 0) {
163+
if (finalAuthorizedClusterNames.length > 0) {
114164
finalDecision = AuthorizeResult.ALLOW;
115165
}
116166

117167
const body = {
118168
decision: finalDecision,
119-
authorizedClusterNames,
120-
authorizeProjects: [],
169+
authorizedClusterNames: finalAuthorizedClusterNames,
170+
authorizeProjects,
121171
};
122172

123173
return response.json(body);

workspaces/redhat-resource-optimization/plugins/redhat-resource-optimization-backend/src/service/optimizationsService.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {
2121
} from '@backstage/backend-plugin-api';
2222
import type { OptimizationsApi } from '@red-hat-developer-hub/plugin-redhat-resource-optimization-common/clients';
2323
import { OptimizationsClient } from '@red-hat-developer-hub/plugin-redhat-resource-optimization-common/clients';
24-
import { DEFAULT_API_BASE_URL } from '../util/constant';
24+
import { DEFAULT_COST_MANAGEMENT_PROXY_BASE_URL } from '../util/constant';
2525

2626
export const optimizationServiceRef = createServiceRef<OptimizationsApi>({
2727
id: 'optimization-client',
@@ -32,9 +32,10 @@ export const optimizationServiceRef = createServiceRef<OptimizationsApi>({
3232
configApi: coreServices.rootConfig,
3333
},
3434
async factory({ configApi }): Promise<OptimizationsApi> {
35+
// Base URL without /cost-management/v1 since OptimizationsClient appends it
3536
const baseUrl =
3637
configApi.getOptionalString('optimizationsBaseUrl') ??
37-
DEFAULT_API_BASE_URL;
38+
DEFAULT_COST_MANAGEMENT_PROXY_BASE_URL;
3839

3940
return new OptimizationsClient({
4041
discoveryApi: {

0 commit comments

Comments
 (0)