Skip to content

Commit 7fdcbfd

Browse files
authored
fix(dashboard): restrict activities API based on namespace access (#171)
* Restrict activities API based on namespace access Signed-off-by: Noah DeMarco <[email protected]> * PR feedback - code readability Signed-off-by: Noah DeMarco <[email protected]> * PR feedback - code readability Signed-off-by: Noah DeMarco <[email protected]> --------- Signed-off-by: Noah DeMarco <[email protected]>
1 parent f4aa50b commit 7fdcbfd

File tree

6 files changed

+496
-4
lines changed

6 files changed

+496
-4
lines changed

components/centraldashboard-angular/backend/app/api.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {Router, Request, Response, NextFunction} from 'express';
22
import {KubernetesService} from './k8s_service';
33
import {Interval, MetricsService} from './metrics_service';
4+
import {WorkgroupApi} from './api_workgroup';
45

56
export const ERRORS = {
67
operation_not_supported: 'Operation not supported',
@@ -20,8 +21,77 @@ export class Api {
2021
constructor(
2122
private k8sService: KubernetesService,
2223
private metricsService?: MetricsService,
24+
private workgroupApi?: WorkgroupApi,
2325
) {}
2426

27+
/**
28+
* Middleware to check if user has access to a specific namespace.
29+
* Users can access a namespace if they:
30+
* - Contain any role binding within the namespace (owner, contributor, or viewer)
31+
* - Are a cluster admin
32+
* - Are in basic auth mode (non-identity aware clusters)
33+
*/
34+
private async checkNamespaceAccess(request: Request, response: Response, next: NextFunction) {
35+
const namespace = request.params.namespace;
36+
if (!namespace) {
37+
return apiError({
38+
res: response,
39+
code: 400,
40+
error: 'Namespace parameter is required',
41+
});
42+
}
43+
44+
// If no workgroup API is configured, allow access (backward compatibility)
45+
if (!this.workgroupApi) {
46+
return next();
47+
}
48+
49+
// If no user is attached to request, deny access
50+
if (!request.user) {
51+
return apiError({
52+
res: response,
53+
code: 401,
54+
error: 'Authentication required to access namespace activities',
55+
});
56+
}
57+
58+
try {
59+
// For non-authenticated users in basic auth mode, allow access
60+
if (!request.user.hasAuth) {
61+
return next();
62+
}
63+
64+
// Get user's workgroup information
65+
const workgroupInfo = await this.workgroupApi.getWorkgroupInfo(request.user);
66+
67+
// Check if user is cluster admin
68+
if (workgroupInfo.isClusterAdmin) {
69+
return next();
70+
}
71+
72+
// Check if user has access to the specific namespace
73+
const hasAccess = workgroupInfo.namespaces.some(
74+
binding => binding.namespace === namespace
75+
);
76+
77+
if (!hasAccess) {
78+
return apiError({
79+
res: response,
80+
code: 403,
81+
error: `Access denied. You do not have permission to view activities for namespace '${namespace}'.`,
82+
});
83+
}
84+
85+
next();
86+
} catch (error) {
87+
console.error('Error checking namespace access:', error);
88+
return apiError({
89+
res: response,
90+
code: 500,
91+
error: 'Unable to verify namespace access permissions',
92+
});
93+
}
94+
}
2595

2696
/**
2797
* Returns the Express router for the API routes.
@@ -65,6 +135,7 @@ export class Api {
65135
})
66136
.get(
67137
'/activities/:namespace',
138+
this.checkNamespaceAccess.bind(this),
68139
async (req: Request, res: Response) => {
69140
res.json(await this.k8sService.getEventsForNamespace(
70141
req.params.namespace));

components/centraldashboard-angular/backend/app/api_test.ts

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import express from 'express';
22
import {get} from 'http';
3+
import {Request, Response, NextFunction} from 'express';
34

45
import {Api} from './api';
56
import {DefaultApi} from './clients/profile_controller';
67
import {KubernetesService} from './k8s_service';
78
import {Interval, MetricsService} from './metrics_service';
9+
import {WorkgroupApi, WorkgroupInfo, SimpleBinding} from './api_workgroup';
810

911
describe('Main API', () => {
1012
let mockK8sService: jasmine.SpyObj<KubernetesService>;
@@ -116,4 +118,176 @@ describe('Main API', () => {
116118
});
117119
});
118120
});
121+
122+
describe('checkNamespaceAccess middleware', () => {
123+
let mockWorkgroupApi: jasmine.SpyObj<WorkgroupApi>;
124+
let api: Api;
125+
let mockReq: Partial<Request>;
126+
let mockRes: Partial<Response>;
127+
let mockNext: jasmine.Spy<NextFunction>;
128+
let jsonSpy: jasmine.Spy;
129+
let statusSpy: jasmine.Spy;
130+
131+
beforeEach(() => {
132+
mockK8sService = jasmine.createSpyObj<KubernetesService>(['']);
133+
mockWorkgroupApi = jasmine.createSpyObj<WorkgroupApi>(['getWorkgroupInfo']);
134+
135+
jsonSpy = jasmine.createSpy('json');
136+
statusSpy = jasmine.createSpy('status').and.returnValue({json: jsonSpy});
137+
138+
mockRes = {
139+
status: statusSpy,
140+
json: jsonSpy,
141+
};
142+
143+
mockNext = jasmine.createSpy('next');
144+
145+
api = new Api(mockK8sService, undefined, mockWorkgroupApi);
146+
});
147+
148+
it('should return 400 if namespace parameter is missing', async () => {
149+
mockReq = {
150+
params: {},
151+
};
152+
153+
// Access the private method via reflection for testing
154+
await (api as any).checkNamespaceAccess(mockReq, mockRes, mockNext);
155+
156+
expect(statusSpy).toHaveBeenCalledWith(400);
157+
expect(jsonSpy).toHaveBeenCalledWith({
158+
error: 'Namespace parameter is required',
159+
});
160+
expect(mockNext).not.toHaveBeenCalled();
161+
});
162+
163+
it('should allow access if no workgroup API is configured', async () => {
164+
const apiWithoutWorkgroup = new Api(mockK8sService, undefined, undefined);
165+
mockReq = {
166+
params: {namespace: 'test-namespace'},
167+
};
168+
169+
await (apiWithoutWorkgroup as any).checkNamespaceAccess(mockReq, mockRes, mockNext);
170+
171+
expect(mockNext).toHaveBeenCalled();
172+
expect(statusSpy).not.toHaveBeenCalled();
173+
});
174+
175+
it('should return 401 if no user is attached to request', async () => {
176+
mockReq = {
177+
params: {namespace: 'test-namespace'},
178+
user: undefined,
179+
};
180+
181+
await (api as any).checkNamespaceAccess(mockReq, mockRes, mockNext);
182+
183+
expect(statusSpy).toHaveBeenCalledWith(401);
184+
expect(jsonSpy).toHaveBeenCalledWith({
185+
error: 'Authentication required to access namespace activities',
186+
});
187+
expect(mockNext).not.toHaveBeenCalled();
188+
});
189+
190+
it('should allow access for non-authenticated users in basic auth mode', async () => {
191+
mockReq = {
192+
params: {namespace: 'test-namespace'},
193+
user: {hasAuth: false},
194+
};
195+
196+
await (api as any).checkNamespaceAccess(mockReq, mockRes, mockNext);
197+
198+
expect(mockNext).toHaveBeenCalled();
199+
expect(statusSpy).not.toHaveBeenCalled();
200+
});
201+
202+
it('should allow access for cluster admins', async () => {
203+
const workgroupInfo: WorkgroupInfo = {
204+
isClusterAdmin: true,
205+
namespaces: [],
206+
};
207+
208+
mockWorkgroupApi.getWorkgroupInfo.and.returnValue(Promise.resolve(workgroupInfo));
209+
210+
mockReq = {
211+
params: {namespace: 'test-namespace'},
212+
user: {hasAuth: true, email: '[email protected]'},
213+
};
214+
215+
await (api as any).checkNamespaceAccess(mockReq, mockRes, mockNext);
216+
217+
expect(mockWorkgroupApi.getWorkgroupInfo).toHaveBeenCalledWith(mockReq.user);
218+
expect(mockNext).toHaveBeenCalled();
219+
expect(statusSpy).not.toHaveBeenCalled();
220+
});
221+
222+
it('should allow access for users with any binding to the namespace', async () => {
223+
const namespaces: SimpleBinding[] = [
224+
{namespace: 'test-namespace', role: 'viewer', user: '[email protected]'},
225+
];
226+
const workgroupInfo: WorkgroupInfo = {
227+
isClusterAdmin: false,
228+
namespaces,
229+
};
230+
231+
mockWorkgroupApi.getWorkgroupInfo.and.returnValue(Promise.resolve(workgroupInfo));
232+
233+
mockReq = {
234+
params: {namespace: 'test-namespace'},
235+
user: {hasAuth: true, email: '[email protected]'},
236+
};
237+
238+
await (api as any).checkNamespaceAccess(mockReq, mockRes, mockNext);
239+
240+
expect(mockWorkgroupApi.getWorkgroupInfo).toHaveBeenCalledWith(mockReq.user);
241+
expect(mockNext).toHaveBeenCalled();
242+
expect(statusSpy).not.toHaveBeenCalled();
243+
});
244+
245+
it('should deny access for users without any binding to the namespace', async () => {
246+
const namespaces: SimpleBinding[] = [
247+
{namespace: 'other-namespace', role: 'owner', user: '[email protected]'},
248+
];
249+
const workgroupInfo: WorkgroupInfo = {
250+
isClusterAdmin: false,
251+
namespaces,
252+
};
253+
254+
mockWorkgroupApi.getWorkgroupInfo.and.returnValue(Promise.resolve(workgroupInfo));
255+
256+
mockReq = {
257+
params: {namespace: 'test-namespace'},
258+
user: {hasAuth: true, email: '[email protected]'},
259+
};
260+
261+
await (api as any).checkNamespaceAccess(mockReq, mockRes, mockNext);
262+
263+
expect(mockWorkgroupApi.getWorkgroupInfo).toHaveBeenCalledWith(mockReq.user);
264+
expect(statusSpy).toHaveBeenCalledWith(403);
265+
expect(jsonSpy).toHaveBeenCalledWith({
266+
error: `Access denied. You do not have permission to view activities for namespace 'test-namespace'.`,
267+
});
268+
expect(mockNext).not.toHaveBeenCalled();
269+
});
270+
271+
it('should return 500 if getWorkgroupInfo throws an error', async () => {
272+
const error = new Error('Service unavailable');
273+
mockWorkgroupApi.getWorkgroupInfo.and.returnValue(Promise.reject(error));
274+
275+
spyOn(console, 'error');
276+
277+
mockReq = {
278+
params: {namespace: 'test-namespace'},
279+
user: {hasAuth: true, email: '[email protected]'},
280+
};
281+
282+
await (api as any).checkNamespaceAccess(mockReq, mockRes, mockNext);
283+
284+
expect(mockWorkgroupApi.getWorkgroupInfo).toHaveBeenCalledWith(mockReq.user);
285+
expect(console.error).toHaveBeenCalledWith('Error checking namespace access:', error);
286+
expect(statusSpy).toHaveBeenCalledWith(500);
287+
expect(jsonSpy).toHaveBeenCalledWith({
288+
error: 'Unable to verify namespace access permissions',
289+
});
290+
expect(mockNext).not.toHaveBeenCalled();
291+
});
292+
});
119293
});

components/centraldashboard-angular/backend/app/server.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,9 @@ async function main() {
6666
message: `I tick, therfore I am!`,
6767
});
6868
});
69-
app.use('/api', new Api(k8sService, metricsService).routes());
70-
app.use('/api/workgroup', new WorkgroupApi(profilesService, k8sService, registrationFlowAllowed, USERID_HEADER).routes());
69+
const workgroupApi = new WorkgroupApi(profilesService, k8sService, registrationFlowAllowed, USERID_HEADER);
70+
app.use('/api', new Api(k8sService, metricsService, workgroupApi).routes());
71+
app.use('/api/workgroup', workgroupApi.routes());
7172
app.use('/api', (req: Request, res: Response) =>
7273
apiError({
7374
res,

components/centraldashboard/app/api.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {Router, Request, Response, NextFunction} from 'express';
22
import {KubernetesService} from './k8s_service';
33
import {Interval, MetricsService} from './metrics_service';
4+
import {WorkgroupApi} from './api_workgroup';
45

56
export const ERRORS = {
67
no_metrics_service_configured: 'No metrics service configured',
@@ -21,8 +22,77 @@ export class Api {
2122
constructor(
2223
private k8sService: KubernetesService,
2324
private metricsService?: MetricsService,
25+
private workgroupApi?: WorkgroupApi,
2426
) {}
2527

28+
/**
29+
* Middleware to check if user has access to a specific namespace.
30+
* Users can access a namespace if they:
31+
* - Contain any role binding within the namespace (owner, contributor, or viewer)
32+
* - Are a cluster admin
33+
* - Are in basic auth mode (non-identity aware clusters)
34+
*/
35+
private async checkNamespaceAccess(request: Request, response: Response, next: NextFunction) {
36+
const namespace = request.params.namespace;
37+
if (!namespace) {
38+
return apiError({
39+
res: response,
40+
code: 400,
41+
error: 'Namespace parameter is required',
42+
});
43+
}
44+
45+
// If no workgroup API is configured, allow access (backward compatibility)
46+
if (!this.workgroupApi) {
47+
return next();
48+
}
49+
50+
// If no user is attached to request, deny access
51+
if (!request.user) {
52+
return apiError({
53+
res: response,
54+
code: 401,
55+
error: 'Authentication required to access namespace activities',
56+
});
57+
}
58+
59+
try {
60+
// For non-authenticated users in basic auth mode, allow access
61+
if (!request.user.hasAuth) {
62+
return next();
63+
}
64+
65+
// Get user's workgroup information
66+
const workgroupInfo = await this.workgroupApi.getWorkgroupInfo(request.user);
67+
68+
// Check if user is cluster admin
69+
if (workgroupInfo.isClusterAdmin) {
70+
return next();
71+
}
72+
73+
// Check if user has access to the specific namespace
74+
const hasAccess = workgroupInfo.namespaces.some(
75+
binding => binding.namespace === namespace
76+
);
77+
78+
if (!hasAccess) {
79+
return apiError({
80+
res: response,
81+
code: 403,
82+
error: `Access denied. You do not have permission to view activities for namespace '${namespace}'.`,
83+
});
84+
}
85+
86+
next();
87+
} catch (error) {
88+
console.error('Error checking namespace access:', error);
89+
return apiError({
90+
res: response,
91+
code: 500,
92+
error: 'Unable to verify namespace access permissions',
93+
});
94+
}
95+
}
2696

2797
/**
2898
* Returns the Express router for the API routes.
@@ -77,6 +147,7 @@ export class Api {
77147
})
78148
.get(
79149
'/activities/:namespace',
150+
this.checkNamespaceAccess.bind(this),
80151
async (req: Request, res: Response) => {
81152
res.json(await this.k8sService.getEventsForNamespace(
82153
req.params.namespace));

0 commit comments

Comments
 (0)