Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@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
---

Add authorization, input validation, and confirmation dialog for Apply Recommendation workflow.

- New `ros.apply` permission required to execute the Apply Recommendation workflow
- New backend `POST /apply-recommendation` endpoint validates `resourceType` against server-side allowlist and checks `ros.apply` permission before forwarding to Orchestrator
- Workflow execution now routes through the cost-management backend instead of directly to the Orchestrator plugin, enabling server-side authorization and audit logging
- Confirmation dialog added before workflow execution to prevent accidental clicks
31 changes: 31 additions & 0 deletions workspaces/cost-management/docs/rbac.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,17 @@ When a frontend request arrives at `/api/cost-management/proxy/*`, the backend:

This means granting `ros.demolab` only allows seeing data for the `demolab` cluster — the user cannot modify query parameters to access other clusters.

### Apply Recommendation authorization

When a user clicks "Apply recommendation", the frontend sends the request to the backend's `/api/cost-management/apply-recommendation` endpoint. The backend:

1. Validates the `resourceType` against a server-side allowlist (`deployment`, `replicaset`, `daemonset`, `statefulset`, `deploymentconfig`, `replicationcontroller`)
2. Checks the `ros.apply` permission — the user must be explicitly granted this permission to execute workflows
3. Forwards the validated request to the Orchestrator plugin using service-to-service authentication
4. Audit logs the action (user, cluster, namespace, workload, workflow ID, outcome)

A confirmation dialog on the frontend also prevents accidental clicks.

## 1. Optimizations Section

The Optimizations section allows users to view resource usage trends and optimization recommendations for workloads running on OpenShift clusters.
Expand All @@ -29,6 +40,7 @@ The Optimizations section allows users to view resource usage trends and optimiz
| ros.plugin | - | read | Allows the user to access all optimization data in the Cost Management plugin |
| ros.[CLUSTER_NAME] | - | read | Allows the user to access optimization data for a specific Cluster in the Cost Management plugin |
| ros.[CLUSTER_NAME].[PROJECT_NAME] | - | read | Allows the user to access optimization data for a specific Project within a specific Cluster in the Cost Management plugin |
| ros.apply | - | update | Allows the user to apply optimization recommendations via workflow execution |

The user is permitted to do an action if either the generic permission or the specific one allows it. In other words, it is not possible to grant generic ros.plugin and then selectively disable it for a specific cluster via ros.[CLUSTER_NAME] with deny.

Expand Down Expand Up @@ -70,6 +82,11 @@ p, role:default/rosUser, ros.demolab, read, allow
p, role:default/rosUser, ros.demolab.thanos, read, allow
p, role:default/rosUser, ros.OpenShift on Azure.mobile, read, allow

####
# Optimizations Section (ros.) - Apply Recommendation permission
####
p, role:default/rosUser, ros.apply, update, allow

####
# OpenShift Section (cost.) - Generic permissions
####
Expand Down Expand Up @@ -131,6 +148,20 @@ p, role:default/rosClusterProjectUser, ros.demolab.thanos, read, allow
g, user:default/test_user_3, role:default/rosClusterProjectUser
```

#### ros.apply Permission

Since the `test_user_7` user has the `default/rosApplyUser` role, which has `ros.apply` permission, it can:

- Execute the Apply Recommendation workflow to modify workload resource configurations

```csv
p, role:default/rosApplyUser, ros.apply, update, allow

g, user:default/test_user_7, role:default/rosApplyUser
```

> **Note:** `ros.apply` is separate from the read permissions. A user can have read access to optimization data without being able to apply recommendations, and vice versa. Typically both `ros.plugin` (or cluster-specific read) and `ros.apply` are granted together.

### OpenShift Cost Section

#### cost.plugin Permission
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import type {
HttpAuthService,
PermissionsService,
CacheService,
DiscoveryService,
AuthService,
} from '@backstage/backend-plugin-api';

/** @public */
Expand All @@ -33,6 +35,8 @@ export interface RouterOptions {
httpAuth: HttpAuthService;
permissions: PermissionsService;
cache: CacheService;
discovery: DiscoveryService;
auth: AuthService;
optimizationApi: OptimizationsApi;
costManagementApi: CostManagementSlimApi;
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ export const costManagementPlugin = createBackendPlugin({
httpAuth: coreServices.httpAuth,
permissions: coreServices.permissions,
cache: coreServices.cache,
discovery: coreServices.discovery,
auth: coreServices.auth,
optimizationApi: optimizationServiceRef,
costManagementApi: costManagementServiceRef,
},
Expand All @@ -48,6 +50,8 @@ export const costManagementPlugin = createBackendPlugin({
httpAuth,
permissions,
cache,
discovery,
auth,
optimizationApi,
costManagementApi,
}) {
Expand All @@ -57,6 +61,8 @@ export const costManagementPlugin = createBackendPlugin({
httpAuth,
permissions,
cache,
discovery,
auth,
optimizationApi,
costManagementApi,
});
Expand All @@ -78,6 +84,10 @@ export const costManagementPlugin = createBackendPlugin({
path: '/proxy',
allow: 'user-cookie',
});
httpRouter.addAuthPolicy({
path: '/apply-recommendation',
allow: 'user-cookie',
});
},
});
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
/*
* 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 express from 'express';
import request from 'supertest';
import { mockServices } from '@backstage/backend-test-utils';
import { AuthorizeResult } from '@backstage/plugin-permission-common';
import { applyRecommendation } from './applyRecommendation';
import type { RouterOptions } from '../models/RouterOptions';

const validBody = {
workflowId: 'patch-k8s-resource',
inputData: {
clusterName: 'test-cluster',
resourceType: 'deployment',
resourceNamespace: 'default',
resourceName: 'my-app',
containerName: 'main',
containerResources: {
limits: { cpu: 0.5, memory: 134217728 },
requests: { cpu: 0.25, memory: 67108864 },
},
},
};

describe('applyRecommendation', () => {
let app: express.Express;
let mockPermissions: ReturnType<typeof mockServices.permissions.mock>;
let mockDiscovery: ReturnType<typeof mockServices.discovery>;

beforeEach(() => {
jest.resetAllMocks();
mockPermissions = mockServices.permissions.mock();
mockDiscovery = mockServices.discovery();

const options: RouterOptions = {
logger: mockServices.rootLogger(),
httpAuth: mockServices.httpAuth(),
permissions: mockPermissions,
cache: mockServices.cache.mock(),
discovery: mockDiscovery,
auth: mockServices.auth(),
optimizationApi: {
getRecommendationList: jest.fn(),
getRecommendationById: jest.fn(),
},
costManagementApi: {
getCostManagementReport: jest.fn(),
downloadCostManagementReport: jest.fn(),
searchOpenShiftProjects: jest.fn(),
searchOpenShiftClusters: jest.fn(),
searchOpenShiftNodes: jest.fn(),
getOpenShiftTags: jest.fn(),
getOpenShiftTagValues: jest.fn(),
},
};

app = express();
app.use(express.json());
app.post('/apply-recommendation', applyRecommendation(options));
});

it('returns 400 when workflowId is missing', async () => {
const response = await request(app)
.post('/apply-recommendation')
.send({ inputData: validBody.inputData });

expect(response.status).toBe(400);
expect(response.body.error).toContain('workflowId');
});

it('returns 400 for invalid resourceType', async () => {
mockPermissions.authorize.mockResolvedValueOnce([
{ result: AuthorizeResult.ALLOW },
]);

const response = await request(app)
.post('/apply-recommendation')
.send({
...validBody,
inputData: { ...validBody.inputData, resourceType: 'cronjob' },
});

expect(response.status).toBe(400);
expect(response.body.error).toContain('Invalid resourceType');
expect(response.body.error).toContain('cronjob');
});

it('returns 400 when required fields are missing', async () => {
const response = await request(app)
.post('/apply-recommendation')
.send({
workflowId: 'patch-k8s-resource',
inputData: {
resourceType: 'deployment',
containerResources: {},
},
});

expect(response.status).toBe(400);
expect(response.body.error).toContain('Missing or invalid');
});

it('returns 403 when ros.apply permission is denied', async () => {
mockPermissions.authorize.mockResolvedValueOnce([
{ result: AuthorizeResult.DENY },
]);

const response = await request(app)
.post('/apply-recommendation')
.send(validBody);

expect(response.status).toBe(403);
expect(response.body.error).toContain('ros.apply');
});

it('validates all allowed resourceType values', async () => {
const allowedTypes = [
'deployment',
'replicaset',
'daemonset',
'statefulset',
'deploymentconfig',
'replicationcontroller',
];

for (const resourceType of allowedTypes) {
mockPermissions.authorize.mockResolvedValueOnce([
{ result: AuthorizeResult.ALLOW },
]);

// eslint-disable-next-line no-restricted-syntax
const fetchSpy = jest
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(
new Response(JSON.stringify({ id: 'instance-1' }), { status: 200 }),
);

const response = await request(app)
.post('/apply-recommendation')
.send({
...validBody,
inputData: { ...validBody.inputData, resourceType },
});

expect(response.status).toBe(200);
fetchSpy.mockRestore();
}
});

it('forwards to orchestrator and returns instance id on success', async () => {
mockPermissions.authorize.mockResolvedValueOnce([
{ result: AuthorizeResult.ALLOW },
]);

const fetchSpy = jest.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
new Response(JSON.stringify({ id: 'workflow-instance-123' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);

const response = await request(app)
.post('/apply-recommendation')
.send(validBody);

expect(response.status).toBe(200);
expect(response.body).toEqual({ id: 'workflow-instance-123' });
expect(fetchSpy).toHaveBeenCalledTimes(1);

const fetchUrl = fetchSpy.mock.calls[0][0] as string;
expect(fetchUrl).toContain('/v2/workflows/patch-k8s-resource/execute');

fetchSpy.mockRestore();
});

it('returns upstream error status on orchestrator failure', async () => {
mockPermissions.authorize.mockResolvedValueOnce([
{ result: AuthorizeResult.ALLOW },
]);

const fetchSpy = jest.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
new Response(JSON.stringify({ error: 'Workflow not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
}),
);

const response = await request(app)
.post('/apply-recommendation')
.send(validBody);

expect(response.status).toBe(404);
expect(response.body).toEqual({ error: 'Workflow not found' });

fetchSpy.mockRestore();
});
});
Loading
Loading