Skip to content

Commit b2fe9d7

Browse files
hardenglclaude
andauthored
fix(cost-management): add structured audit logging with user identity (#2619)
* fix(cost-management): add structured audit logging with user identity - New auditLog utility resolves user identity via UserInfoService and emits structured JSON audit entries with actor, action, resource, decision, and filters - All endpoints now produce audit logs: secureProxy, /access, /access/cost-management, and /apply-recommendation - Inject coreServices.userInfo into the backend plugin Fixes: FLPATH-3490 Made-with: Cursor * fix(cost-management): log warning when actor identity resolution fails Add logger.warn in resolveActor catch block so operators can detect and diagnose identity resolution failures instead of silently falling back to 'unknown' actor. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ce8cb07 commit b2fe9d7

10 files changed

Lines changed: 279 additions & 25 deletions

File tree

workspaces/cost-management/plugins/cost-management-backend/src/models/RouterOptions.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import type {
2626
CacheService,
2727
DiscoveryService,
2828
AuthService,
29+
UserInfoService,
2930
} from '@backstage/backend-plugin-api';
3031

3132
/** @public */
@@ -37,6 +38,7 @@ export interface RouterOptions {
3738
cache: CacheService;
3839
discovery: DiscoveryService;
3940
auth: AuthService;
41+
userInfo: UserInfoService;
4042
optimizationApi: OptimizationsApi;
4143
costManagementApi: CostManagementSlimApi;
4244
}

workspaces/cost-management/plugins/cost-management-backend/src/plugin.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export const costManagementPlugin = createBackendPlugin({
4040
cache: coreServices.cache,
4141
discovery: coreServices.discovery,
4242
auth: coreServices.auth,
43+
userInfo: coreServices.userInfo,
4344
optimizationApi: optimizationServiceRef,
4445
costManagementApi: costManagementServiceRef,
4546
},
@@ -52,6 +53,7 @@ export const costManagementPlugin = createBackendPlugin({
5253
cache,
5354
discovery,
5455
auth,
56+
userInfo,
5557
optimizationApi,
5658
costManagementApi,
5759
}) {
@@ -63,6 +65,7 @@ export const costManagementPlugin = createBackendPlugin({
6365
cache,
6466
discovery,
6567
auth,
68+
userInfo,
6669
optimizationApi,
6770
costManagementApi,
6871
});

workspaces/cost-management/plugins/cost-management-backend/src/routes/access.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
import { rosPluginPermissions } from '@red-hat-developer-hub/plugin-cost-management-common/permissions';
2424
import { getTokenFromApi } from '../util/tokenUtil';
2525
import { AuthorizeResult } from '@backstage/plugin-permission-common';
26+
import { resolveActor, emitAuditLog } from '../util/auditLog';
2627

2728
export const getAccess: (options: RouterOptions) => RequestHandler =
2829
options => async (_, response) => {
@@ -43,6 +44,14 @@ export const getAccess: (options: RouterOptions) => RequestHandler =
4344
if (rosPluginDecision.result === AuthorizeResult.ALLOW) {
4445
finalDecision = AuthorizeResult.ALLOW;
4546

47+
const actor = await resolveActor(_, options);
48+
emitAuditLog(options, {
49+
actor,
50+
action: 'access_check',
51+
resource: '/access',
52+
decision: 'ALLOW',
53+
});
54+
4655
const body = {
4756
decision: finalDecision,
4857
authorizeClusterIds: [],
@@ -186,6 +195,18 @@ export const getAccess: (options: RouterOptions) => RequestHandler =
186195
finalDecision = AuthorizeResult.DENY;
187196
}
188197

198+
const actor = await resolveActor(_, options);
199+
emitAuditLog(options, {
200+
actor,
201+
action: 'access_check',
202+
resource: '/access',
203+
decision: finalDecision === AuthorizeResult.ALLOW ? 'ALLOW' : 'DENY',
204+
filters: {
205+
clusters: finalAuthorizedClusterIds,
206+
projects: authorizeProjects,
207+
},
208+
});
209+
189210
const body = {
190211
decision: finalDecision,
191212
authorizeClusterIds: finalAuthorizedClusterIds,

workspaces/cost-management/plugins/cost-management-backend/src/routes/applyRecommendation.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ describe('applyRecommendation', () => {
5353
cache: mockServices.cache.mock(),
5454
discovery: mockDiscovery,
5555
auth: mockServices.auth(),
56+
userInfo: mockServices.userInfo(),
5657
optimizationApi: {
5758
getRecommendationList: jest.fn(),
5859
getRecommendationById: jest.fn(),

workspaces/cost-management/plugins/cost-management-backend/src/routes/applyRecommendation.ts

Lines changed: 37 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type { RouterOptions } from '../models/RouterOptions';
1919
import { authorize } from '../util/checkPermissions';
2020
import { rosApplyPermissions } from '@red-hat-developer-hub/plugin-cost-management-common/permissions';
2121
import { AuthorizeResult } from '@backstage/plugin-permission-common';
22+
import { resolveActor, emitAuditLog } from '../util/auditLog';
2223

2324
const ALLOWED_RESOURCE_TYPES = new Set([
2425
'deployment',
@@ -104,21 +105,27 @@ export const applyRecommendation: (options: RouterOptions) => RequestHandler =
104105
}
105106
const { workflowId, inputData } = validation.data;
106107

108+
const actor = await resolveActor(req, options);
109+
107110
const decision = await authorize(
108111
req,
109112
rosApplyPermissions,
110113
permissions,
111114
httpAuth,
112115
);
113116
if (decision.result !== AuthorizeResult.ALLOW) {
114-
logger.info('audit:apply-recommendation:denied', {
117+
emitAuditLog(options, {
118+
actor,
115119
action: 'apply_recommendation',
120+
resource: `/apply-recommendation/${workflowId}`,
116121
decision: 'DENY',
117-
workflowId,
118-
cluster: inputData.clusterName,
119-
namespace: inputData.resourceNamespace,
120-
workload: inputData.resourceName,
121-
resourceType: inputData.resourceType,
122+
meta: {
123+
workflowId,
124+
cluster: inputData.clusterName,
125+
namespace: inputData.resourceNamespace,
126+
workload: inputData.resourceName,
127+
resourceType: inputData.resourceType,
128+
},
122129
});
123130
return res
124131
.status(403)
@@ -165,28 +172,38 @@ export const applyRecommendation: (options: RouterOptions) => RequestHandler =
165172
}
166173

167174
if (!upstreamResponse.ok) {
168-
logger.warn('audit:apply-recommendation:upstream-error', {
175+
emitAuditLog(options, {
176+
actor,
169177
action: 'apply_recommendation',
178+
resource: `/apply-recommendation/${workflowId}`,
170179
decision: 'ALLOW',
171-
workflowId,
172-
cluster: inputData.clusterName,
173-
namespace: inputData.resourceNamespace,
174-
workload: inputData.resourceName,
175-
resourceType: inputData.resourceType,
176-
upstreamStatus: upstreamResponse.status,
180+
meta: {
181+
workflowId,
182+
cluster: inputData.clusterName,
183+
namespace: inputData.resourceNamespace,
184+
workload: inputData.resourceName,
185+
resourceType: inputData.resourceType,
186+
upstreamStatus: upstreamResponse.status,
187+
outcome: 'upstream_error',
188+
},
177189
});
178190
return res.status(upstreamResponse.status).json(payload);
179191
}
180192

181-
logger.info('audit:apply-recommendation:success', {
193+
emitAuditLog(options, {
194+
actor,
182195
action: 'apply_recommendation',
196+
resource: `/apply-recommendation/${workflowId}`,
183197
decision: 'ALLOW',
184-
workflowId,
185-
instanceId: (payload as { id?: string }).id,
186-
cluster: inputData.clusterName,
187-
namespace: inputData.resourceNamespace,
188-
workload: inputData.resourceName,
189-
resourceType: inputData.resourceType,
198+
meta: {
199+
workflowId,
200+
instanceId: (payload as { id?: string }).id,
201+
cluster: inputData.clusterName,
202+
namespace: inputData.resourceNamespace,
203+
workload: inputData.resourceName,
204+
resourceType: inputData.resourceType,
205+
outcome: 'success',
206+
},
190207
});
191208

192209
return res.status(200).json(payload);

workspaces/cost-management/plugins/cost-management-backend/src/routes/costManagementAccess.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
import { costPluginPermissions } from '@red-hat-developer-hub/plugin-cost-management-common/permissions';
2424
import { AuthorizeResult } from '@backstage/plugin-permission-common';
2525
import { getTokenFromApi } from '../util/tokenUtil';
26+
import { resolveActor, emitAuditLog } from '../util/auditLog';
2627

2728
// Cache keys for cost management clusters and projects
2829
const COST_CLUSTERS_CACHE_KEY = 'cost_clusters';
@@ -49,6 +50,14 @@ export const getCostManagementAccess: (
4950
if (costPluginDecision.result === AuthorizeResult.ALLOW) {
5051
finalDecision = AuthorizeResult.ALLOW;
5152

53+
const actor = await resolveActor(_, options);
54+
emitAuditLog(options, {
55+
actor,
56+
action: 'access_check',
57+
resource: '/access/cost-management',
58+
decision: 'ALLOW',
59+
});
60+
5261
const body = {
5362
decision: finalDecision,
5463
authorizedClusterNames: [],
@@ -164,6 +173,18 @@ export const getCostManagementAccess: (
164173
finalDecision = AuthorizeResult.ALLOW;
165174
}
166175

176+
const actor = await resolveActor(_, options);
177+
emitAuditLog(options, {
178+
actor,
179+
action: 'access_check',
180+
resource: '/access/cost-management',
181+
decision: finalDecision === AuthorizeResult.ALLOW ? 'ALLOW' : 'DENY',
182+
filters: {
183+
clusters: finalAuthorizedClusterNames,
184+
projects: authorizeProjects,
185+
},
186+
});
187+
167188
const body = {
168189
decision: finalDecision,
169190
authorizedClusterNames: finalAuthorizedClusterNames,

workspaces/cost-management/plugins/cost-management-backend/src/routes/secureProxy.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
import { AuthorizeResult } from '@backstage/plugin-permission-common';
2828
import { getTokenFromApi } from '../util/tokenUtil';
2929
import { DEFAULT_COST_MANAGEMENT_PROXY_BASE_URL } from '../util/constant';
30+
import { resolveActor, emitAuditLog } from '../util/auditLog';
3031

3132
const CACHE_TTL = 15 * 60 * 1000;
3233

@@ -290,7 +291,7 @@ function injectRbacFilters(targetUrl: URL, access: AccessResult): void {
290291
*/
291292
export const secureProxy: (options: RouterOptions) => RequestHandler =
292293
options => async (req, res) => {
293-
const { logger, config } = options;
294+
const { config } = options;
294295
const proxyPath = req.params[0];
295296

296297
if (!proxyPath) {
@@ -305,8 +306,19 @@ export const secureProxy: (options: RouterOptions) => RequestHandler =
305306

306307
try {
307308
const access = await resolveAccess(req, proxyPath, options);
309+
const actor = await resolveActor(req, options);
308310

309311
if (access.decision !== 'ALLOW') {
312+
emitAuditLog(options, {
313+
actor,
314+
action: 'data_access',
315+
resource: proxyPath,
316+
decision: 'DENY',
317+
filters: {
318+
clusters: access.clusterFilters,
319+
projects: access.projectFilters,
320+
},
321+
});
310322
return res.status(403).json({ error: 'Access denied by RBAC policy' });
311323
}
312324

@@ -348,9 +360,16 @@ export const secureProxy: (options: RouterOptions) => RequestHandler =
348360

349361
injectRbacFilters(targetUrl, access);
350362

351-
logger.info(
352-
`Proxying ${req.method} to ${targetUrl.pathname}${targetUrl.search}`,
353-
);
363+
emitAuditLog(options, {
364+
actor,
365+
action: 'data_access',
366+
resource: proxyPath,
367+
decision: 'ALLOW',
368+
filters: {
369+
clusters: access.clusterFilters,
370+
projects: access.projectFilters,
371+
},
372+
});
354373

355374
const upstreamResponse = await fetch(targetUrl.toString(), {
356375
headers: {
@@ -371,7 +390,7 @@ export const secureProxy: (options: RouterOptions) => RequestHandler =
371390
res.set('Content-Type', contentType);
372391
return res.send(await upstreamResponse.text());
373392
} catch (error) {
374-
logger.error('Secure proxy error', error);
393+
options.logger.error('Secure proxy error', error);
375394
return res.status(500).json({ error: 'Internal proxy error' });
376395
}
377396
};

workspaces/cost-management/plugins/cost-management-backend/src/service/router.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ describe('createRouter', () => {
3131
cache: mockServices.cache.mock(),
3232
discovery: mockServices.discovery(),
3333
auth: mockServices.auth(),
34+
userInfo: mockServices.userInfo(),
3435
optimizationApi: {
3536
getRecommendationList: jest.fn(),
3637
getRecommendationById: jest.fn(),

0 commit comments

Comments
 (0)