From e5e9fdffde38a5c847550457b606a05d551f13c8 Mon Sep 17 00:00:00 2001 From: Noah DeMarco Date: Wed, 26 Nov 2025 13:33:40 -0500 Subject: [PATCH] Restrict activities API based on namespace access --- .../backend/app/api.ts | 71 +++++++ .../backend/app/api_test.ts | 174 ++++++++++++++++++ .../backend/app/server.ts | 5 +- components/centraldashboard/app/api.ts | 71 +++++++ components/centraldashboard/app/api_test.ts | 174 ++++++++++++++++++ components/centraldashboard/app/server.ts | 5 +- 6 files changed, 496 insertions(+), 4 deletions(-) diff --git a/components/centraldashboard-angular/backend/app/api.ts b/components/centraldashboard-angular/backend/app/api.ts index 9caa5eaad..b0f20b5ec 100644 --- a/components/centraldashboard-angular/backend/app/api.ts +++ b/components/centraldashboard-angular/backend/app/api.ts @@ -1,6 +1,7 @@ import {Router, Request, Response, NextFunction} from 'express'; import {KubernetesService} from './k8s_service'; import {Interval, MetricsService} from './metrics_service'; +import {WorkgroupApi} from './api_workgroup'; export const ERRORS = { operation_not_supported: 'Operation not supported', @@ -20,8 +21,77 @@ export class Api { constructor( private k8sService: KubernetesService, private metricsService?: MetricsService, + private workgroupApi?: WorkgroupApi, ) {} + /** + * Middleware to check if user has access to a specific namespace. + * Users can access a namespace if they: + * - Contain any role binding within the namespace (owner, contributor, or viewer) + * - Are a cluster admin + * - Are in basic auth mode (non-identity aware clusters) + */ + private async checkNamespaceAccess(req: Request, res: Response, next: NextFunction) { + const namespace = req.params.namespace; + if (!namespace) { + return apiError({ + res, + code: 400, + error: 'Namespace parameter is required', + }); + } + + // If no workgroup API is configured, allow access (backward compatibility) + if (!this.workgroupApi) { + return next(); + } + + // If no user is attached to request, deny access + if (!req.user) { + return apiError({ + res, + code: 401, + error: 'Authentication required to access namespace activities', + }); + } + + try { + // For non-authenticated users in basic auth mode, allow access + if (!req.user.hasAuth) { + return next(); + } + + // Get user's workgroup information + const workgroupInfo = await this.workgroupApi.getWorkgroupInfo(req.user); + + // Check if user is cluster admin + if (workgroupInfo.isClusterAdmin) { + return next(); + } + + // Check if user has access to the specific namespace + const hasAccess = workgroupInfo.namespaces.some( + binding => binding.namespace === namespace + ); + + if (!hasAccess) { + return apiError({ + res, + code: 403, + error: `Access denied. You do not have permission to view activities for namespace '${namespace}'.`, + }); + } + + next(); + } catch (err) { + console.error('Error checking namespace access:', err); + return apiError({ + res, + code: 500, + error: 'Unable to verify namespace access permissions', + }); + } + } /** * Returns the Express router for the API routes. @@ -65,6 +135,7 @@ export class Api { }) .get( '/activities/:namespace', + this.checkNamespaceAccess.bind(this), async (req: Request, res: Response) => { res.json(await this.k8sService.getEventsForNamespace( req.params.namespace)); diff --git a/components/centraldashboard-angular/backend/app/api_test.ts b/components/centraldashboard-angular/backend/app/api_test.ts index 9ddc34405..66451a91f 100644 --- a/components/centraldashboard-angular/backend/app/api_test.ts +++ b/components/centraldashboard-angular/backend/app/api_test.ts @@ -1,10 +1,12 @@ import express from 'express'; import {get} from 'http'; +import {Request, Response, NextFunction} from 'express'; import {Api} from './api'; import {DefaultApi} from './clients/profile_controller'; import {KubernetesService} from './k8s_service'; import {Interval, MetricsService} from './metrics_service'; +import {WorkgroupApi, WorkgroupInfo, SimpleBinding} from './api_workgroup'; describe('Main API', () => { let mockK8sService: jasmine.SpyObj; @@ -116,4 +118,176 @@ describe('Main API', () => { }); }); }); + + describe('checkNamespaceAccess middleware', () => { + let mockWorkgroupApi: jasmine.SpyObj; + let api: Api; + let mockReq: Partial; + let mockRes: Partial; + let mockNext: jasmine.Spy; + let jsonSpy: jasmine.Spy; + let statusSpy: jasmine.Spy; + + beforeEach(() => { + mockK8sService = jasmine.createSpyObj(['']); + mockWorkgroupApi = jasmine.createSpyObj(['getWorkgroupInfo']); + + jsonSpy = jasmine.createSpy('json'); + statusSpy = jasmine.createSpy('status').and.returnValue({json: jsonSpy}); + + mockRes = { + status: statusSpy, + json: jsonSpy, + }; + + mockNext = jasmine.createSpy('next'); + + api = new Api(mockK8sService, undefined, mockWorkgroupApi); + }); + + it('should return 400 if namespace parameter is missing', async () => { + mockReq = { + params: {}, + }; + + // Access the private method via reflection for testing + await (api as any).checkNamespaceAccess(mockReq, mockRes, mockNext); + + expect(statusSpy).toHaveBeenCalledWith(400); + expect(jsonSpy).toHaveBeenCalledWith({ + error: 'Namespace parameter is required', + }); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it('should allow access if no workgroup API is configured', async () => { + const apiWithoutWorkgroup = new Api(mockK8sService, undefined, undefined); + mockReq = { + params: {namespace: 'test-namespace'}, + }; + + await (apiWithoutWorkgroup as any).checkNamespaceAccess(mockReq, mockRes, mockNext); + + expect(mockNext).toHaveBeenCalled(); + expect(statusSpy).not.toHaveBeenCalled(); + }); + + it('should return 401 if no user is attached to request', async () => { + mockReq = { + params: {namespace: 'test-namespace'}, + user: undefined, + }; + + await (api as any).checkNamespaceAccess(mockReq, mockRes, mockNext); + + expect(statusSpy).toHaveBeenCalledWith(401); + expect(jsonSpy).toHaveBeenCalledWith({ + error: 'Authentication required to access namespace activities', + }); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it('should allow access for non-authenticated users in basic auth mode', async () => { + mockReq = { + params: {namespace: 'test-namespace'}, + user: {hasAuth: false}, + }; + + await (api as any).checkNamespaceAccess(mockReq, mockRes, mockNext); + + expect(mockNext).toHaveBeenCalled(); + expect(statusSpy).not.toHaveBeenCalled(); + }); + + it('should allow access for cluster admins', async () => { + const workgroupInfo: WorkgroupInfo = { + isClusterAdmin: true, + namespaces: [], + }; + + mockWorkgroupApi.getWorkgroupInfo.and.returnValue(Promise.resolve(workgroupInfo)); + + mockReq = { + params: {namespace: 'test-namespace'}, + user: {hasAuth: true, email: 'admin@example.com'}, + }; + + await (api as any).checkNamespaceAccess(mockReq, mockRes, mockNext); + + expect(mockWorkgroupApi.getWorkgroupInfo).toHaveBeenCalledWith(mockReq.user); + expect(mockNext).toHaveBeenCalled(); + expect(statusSpy).not.toHaveBeenCalled(); + }); + + it('should allow access for users with any binding to the namespace', async () => { + const namespaces: SimpleBinding[] = [ + {namespace: 'test-namespace', role: 'viewer', user: 'user@example.com'}, + ]; + const workgroupInfo: WorkgroupInfo = { + isClusterAdmin: false, + namespaces, + }; + + mockWorkgroupApi.getWorkgroupInfo.and.returnValue(Promise.resolve(workgroupInfo)); + + mockReq = { + params: {namespace: 'test-namespace'}, + user: {hasAuth: true, email: 'user@example.com'}, + }; + + await (api as any).checkNamespaceAccess(mockReq, mockRes, mockNext); + + expect(mockWorkgroupApi.getWorkgroupInfo).toHaveBeenCalledWith(mockReq.user); + expect(mockNext).toHaveBeenCalled(); + expect(statusSpy).not.toHaveBeenCalled(); + }); + + it('should deny access for users without any binding to the namespace', async () => { + const namespaces: SimpleBinding[] = [ + {namespace: 'other-namespace', role: 'owner', user: 'user@example.com'}, + ]; + const workgroupInfo: WorkgroupInfo = { + isClusterAdmin: false, + namespaces, + }; + + mockWorkgroupApi.getWorkgroupInfo.and.returnValue(Promise.resolve(workgroupInfo)); + + mockReq = { + params: {namespace: 'test-namespace'}, + user: {hasAuth: true, email: 'user@example.com'}, + }; + + await (api as any).checkNamespaceAccess(mockReq, mockRes, mockNext); + + expect(mockWorkgroupApi.getWorkgroupInfo).toHaveBeenCalledWith(mockReq.user); + expect(statusSpy).toHaveBeenCalledWith(403); + expect(jsonSpy).toHaveBeenCalledWith({ + error: `Access denied. You do not have permission to view activities for namespace 'test-namespace'.`, + }); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it('should return 500 if getWorkgroupInfo throws an error', async () => { + const error = new Error('Service unavailable'); + mockWorkgroupApi.getWorkgroupInfo.and.returnValue(Promise.reject(error)); + + spyOn(console, 'error'); + + mockReq = { + params: {namespace: 'test-namespace'}, + user: {hasAuth: true, email: 'user@example.com'}, + }; + + await (api as any).checkNamespaceAccess(mockReq, mockRes, mockNext); + + expect(mockWorkgroupApi.getWorkgroupInfo).toHaveBeenCalledWith(mockReq.user); + expect(console.error).toHaveBeenCalledWith('Error checking namespace access:', error); + expect(statusSpy).toHaveBeenCalledWith(500); + expect(jsonSpy).toHaveBeenCalledWith({ + error: 'Unable to verify namespace access permissions', + }); + expect(mockNext).not.toHaveBeenCalled(); + }); + }); }); diff --git a/components/centraldashboard-angular/backend/app/server.ts b/components/centraldashboard-angular/backend/app/server.ts index ab17a220a..6e57aeacc 100644 --- a/components/centraldashboard-angular/backend/app/server.ts +++ b/components/centraldashboard-angular/backend/app/server.ts @@ -66,8 +66,9 @@ async function main() { message: `I tick, therfore I am!`, }); }); - app.use('/api', new Api(k8sService, metricsService).routes()); - app.use('/api/workgroup', new WorkgroupApi(profilesService, k8sService, registrationFlowAllowed, USERID_HEADER).routes()); + const workgroupApi = new WorkgroupApi(profilesService, k8sService, registrationFlowAllowed, USERID_HEADER); + app.use('/api', new Api(k8sService, metricsService, workgroupApi).routes()); + app.use('/api/workgroup', workgroupApi.routes()); app.use('/api', (req: Request, res: Response) => apiError({ res, diff --git a/components/centraldashboard/app/api.ts b/components/centraldashboard/app/api.ts index 808dc486d..2a6c1a7e8 100644 --- a/components/centraldashboard/app/api.ts +++ b/components/centraldashboard/app/api.ts @@ -1,6 +1,7 @@ import {Router, Request, Response, NextFunction} from 'express'; import {KubernetesService} from './k8s_service'; import {Interval, MetricsService} from './metrics_service'; +import {WorkgroupApi} from './api_workgroup'; export const ERRORS = { no_metrics_service_configured: 'No metrics service configured', @@ -21,8 +22,77 @@ export class Api { constructor( private k8sService: KubernetesService, private metricsService?: MetricsService, + private workgroupApi?: WorkgroupApi, ) {} + /** + * Middleware to check if user has access to a specific namespace. + * Users can access a namespace if they: + * - Contain any role binding within the namespace (owner, contributor, or viewer) + * - Are a cluster admin + * - Are in basic auth mode (non-identity aware clusters) + */ + private async checkNamespaceAccess(req: Request, res: Response, next: NextFunction) { + const namespace = req.params.namespace; + if (!namespace) { + return apiError({ + res, + code: 400, + error: 'Namespace parameter is required', + }); + } + + // If no workgroup API is configured, allow access (backward compatibility) + if (!this.workgroupApi) { + return next(); + } + + // If no user is attached to request, deny access + if (!req.user) { + return apiError({ + res, + code: 401, + error: 'Authentication required to access namespace activities', + }); + } + + try { + // For non-authenticated users in basic auth mode, allow access + if (!req.user.hasAuth) { + return next(); + } + + // Get user's workgroup information + const workgroupInfo = await this.workgroupApi.getWorkgroupInfo(req.user); + + // Check if user is cluster admin + if (workgroupInfo.isClusterAdmin) { + return next(); + } + + // Check if user has access to the specific namespace + const hasAccess = workgroupInfo.namespaces.some( + binding => binding.namespace === namespace + ); + + if (!hasAccess) { + return apiError({ + res, + code: 403, + error: `Access denied. You do not have permission to view activities for namespace '${namespace}'.`, + }); + } + + next(); + } catch (err) { + console.error('Error checking namespace access:', err); + return apiError({ + res, + code: 500, + error: 'Unable to verify namespace access permissions', + }); + } + } /** * Returns the Express router for the API routes. @@ -77,6 +147,7 @@ export class Api { }) .get( '/activities/:namespace', + this.checkNamespaceAccess.bind(this), async (req: Request, res: Response) => { res.json(await this.k8sService.getEventsForNamespace( req.params.namespace)); diff --git a/components/centraldashboard/app/api_test.ts b/components/centraldashboard/app/api_test.ts index 810c12c87..ec113163d 100644 --- a/components/centraldashboard/app/api_test.ts +++ b/components/centraldashboard/app/api_test.ts @@ -1,10 +1,12 @@ import express from 'express'; import {get} from 'http'; +import {Request, Response, NextFunction} from 'express'; import {Api} from './api'; import {DefaultApi} from './clients/profile_controller'; import {KubernetesService} from './k8s_service'; import {Interval, MetricsService} from './metrics_service'; +import {WorkgroupApi, WorkgroupInfo, SimpleBinding} from './api_workgroup'; describe('Main API', () => { let mockK8sService: jasmine.SpyObj; @@ -136,4 +138,176 @@ describe('Main API', () => { }); }); }); + + describe('checkNamespaceAccess middleware', () => { + let mockWorkgroupApi: jasmine.SpyObj; + let api: Api; + let mockReq: Partial; + let mockRes: Partial; + let mockNext: jasmine.Spy; + let jsonSpy: jasmine.Spy; + let statusSpy: jasmine.Spy; + + beforeEach(() => { + mockK8sService = jasmine.createSpyObj(['']); + mockWorkgroupApi = jasmine.createSpyObj(['getWorkgroupInfo']); + + jsonSpy = jasmine.createSpy('json'); + statusSpy = jasmine.createSpy('status').and.returnValue({json: jsonSpy}); + + mockRes = { + status: statusSpy, + json: jsonSpy, + }; + + mockNext = jasmine.createSpy('next'); + + api = new Api(mockK8sService, undefined, mockWorkgroupApi); + }); + + it('should return 400 if namespace parameter is missing', async () => { + mockReq = { + params: {}, + }; + + // Access the private method via reflection for testing + await (api as any).checkNamespaceAccess(mockReq, mockRes, mockNext); + + expect(statusSpy).toHaveBeenCalledWith(400); + expect(jsonSpy).toHaveBeenCalledWith({ + error: 'Namespace parameter is required', + }); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it('should allow access if no workgroup API is configured', async () => { + const apiWithoutWorkgroup = new Api(mockK8sService, undefined, undefined); + mockReq = { + params: {namespace: 'test-namespace'}, + }; + + await (apiWithoutWorkgroup as any).checkNamespaceAccess(mockReq, mockRes, mockNext); + + expect(mockNext).toHaveBeenCalled(); + expect(statusSpy).not.toHaveBeenCalled(); + }); + + it('should return 401 if no user is attached to request', async () => { + mockReq = { + params: {namespace: 'test-namespace'}, + user: undefined, + }; + + await (api as any).checkNamespaceAccess(mockReq, mockRes, mockNext); + + expect(statusSpy).toHaveBeenCalledWith(401); + expect(jsonSpy).toHaveBeenCalledWith({ + error: 'Authentication required to access namespace activities', + }); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it('should allow access for non-authenticated users in basic auth mode', async () => { + mockReq = { + params: {namespace: 'test-namespace'}, + user: {hasAuth: false}, + }; + + await (api as any).checkNamespaceAccess(mockReq, mockRes, mockNext); + + expect(mockNext).toHaveBeenCalled(); + expect(statusSpy).not.toHaveBeenCalled(); + }); + + it('should allow access for cluster admins', async () => { + const workgroupInfo: WorkgroupInfo = { + isClusterAdmin: true, + namespaces: [], + }; + + mockWorkgroupApi.getWorkgroupInfo.and.returnValue(Promise.resolve(workgroupInfo)); + + mockReq = { + params: {namespace: 'test-namespace'}, + user: {hasAuth: true, email: 'admin@example.com'}, + }; + + await (api as any).checkNamespaceAccess(mockReq, mockRes, mockNext); + + expect(mockWorkgroupApi.getWorkgroupInfo).toHaveBeenCalledWith(mockReq.user); + expect(mockNext).toHaveBeenCalled(); + expect(statusSpy).not.toHaveBeenCalled(); + }); + + it('should allow access for users with any binding to the namespace', async () => { + const namespaces: SimpleBinding[] = [ + {namespace: 'test-namespace', role: 'viewer', user: 'user@example.com'}, + ]; + const workgroupInfo: WorkgroupInfo = { + isClusterAdmin: false, + namespaces, + }; + + mockWorkgroupApi.getWorkgroupInfo.and.returnValue(Promise.resolve(workgroupInfo)); + + mockReq = { + params: {namespace: 'test-namespace'}, + user: {hasAuth: true, email: 'user@example.com'}, + }; + + await (api as any).checkNamespaceAccess(mockReq, mockRes, mockNext); + + expect(mockWorkgroupApi.getWorkgroupInfo).toHaveBeenCalledWith(mockReq.user); + expect(mockNext).toHaveBeenCalled(); + expect(statusSpy).not.toHaveBeenCalled(); + }); + + it('should deny access for users without any binding to the namespace', async () => { + const namespaces: SimpleBinding[] = [ + {namespace: 'other-namespace', role: 'owner', user: 'user@example.com'}, + ]; + const workgroupInfo: WorkgroupInfo = { + isClusterAdmin: false, + namespaces, + }; + + mockWorkgroupApi.getWorkgroupInfo.and.returnValue(Promise.resolve(workgroupInfo)); + + mockReq = { + params: {namespace: 'test-namespace'}, + user: {hasAuth: true, email: 'user@example.com'}, + }; + + await (api as any).checkNamespaceAccess(mockReq, mockRes, mockNext); + + expect(mockWorkgroupApi.getWorkgroupInfo).toHaveBeenCalledWith(mockReq.user); + expect(statusSpy).toHaveBeenCalledWith(403); + expect(jsonSpy).toHaveBeenCalledWith({ + error: `Access denied. You do not have permission to view activities for namespace 'test-namespace'.`, + }); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it('should return 500 if getWorkgroupInfo throws an error', async () => { + const error = new Error('Service unavailable'); + mockWorkgroupApi.getWorkgroupInfo.and.returnValue(Promise.reject(error)); + + spyOn(console, 'error'); + + mockReq = { + params: {namespace: 'test-namespace'}, + user: {hasAuth: true, email: 'user@example.com'}, + }; + + await (api as any).checkNamespaceAccess(mockReq, mockRes, mockNext); + + expect(mockWorkgroupApi.getWorkgroupInfo).toHaveBeenCalledWith(mockReq.user); + expect(console.error).toHaveBeenCalledWith('Error checking namespace access:', error); + expect(statusSpy).toHaveBeenCalledWith(500); + expect(jsonSpy).toHaveBeenCalledWith({ + error: 'Unable to verify namespace access permissions', + }); + expect(mockNext).not.toHaveBeenCalled(); + }); + }); }); diff --git a/components/centraldashboard/app/server.ts b/components/centraldashboard/app/server.ts index b44a7eb95..5a0ddc1b0 100644 --- a/components/centraldashboard/app/server.ts +++ b/components/centraldashboard/app/server.ts @@ -83,8 +83,9 @@ async function main() { message: `I tick, therfore I am!`, }); }); - app.use('/api', new Api(k8sService, metricsService).routes()); - app.use('/api/workgroup', new WorkgroupApi(profilesService, k8sService, registrationFlowAllowed, USERID_HEADER).routes()); + const workgroupApi = new WorkgroupApi(profilesService, k8sService, registrationFlowAllowed, USERID_HEADER); + app.use('/api', new Api(k8sService, metricsService, workgroupApi).routes()); + app.use('/api/workgroup', workgroupApi.routes()); app.use('/api', (req: Request, res: Response) => apiError({ res,