Skip to content
Open
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
71 changes: 71 additions & 0 deletions components/centraldashboard-angular/backend/app/api.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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.
Expand Down Expand Up @@ -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));
Expand Down
174 changes: 174 additions & 0 deletions components/centraldashboard-angular/backend/app/api_test.ts
Original file line number Diff line number Diff line change
@@ -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<KubernetesService>;
Expand Down Expand Up @@ -116,4 +118,176 @@ describe('Main API', () => {
});
});
});

describe('checkNamespaceAccess middleware', () => {
let mockWorkgroupApi: jasmine.SpyObj<WorkgroupApi>;
let api: Api;
let mockReq: Partial<Request>;
let mockRes: Partial<Response>;
let mockNext: jasmine.Spy<NextFunction>;
let jsonSpy: jasmine.Spy;
let statusSpy: jasmine.Spy;

beforeEach(() => {
mockK8sService = jasmine.createSpyObj<KubernetesService>(['']);
mockWorkgroupApi = jasmine.createSpyObj<WorkgroupApi>(['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: '[email protected]'},
};

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: '[email protected]'},
];
const workgroupInfo: WorkgroupInfo = {
isClusterAdmin: false,
namespaces,
};

mockWorkgroupApi.getWorkgroupInfo.and.returnValue(Promise.resolve(workgroupInfo));

mockReq = {
params: {namespace: 'test-namespace'},
user: {hasAuth: true, email: '[email protected]'},
};

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: '[email protected]'},
];
const workgroupInfo: WorkgroupInfo = {
isClusterAdmin: false,
namespaces,
};

mockWorkgroupApi.getWorkgroupInfo.and.returnValue(Promise.resolve(workgroupInfo));

mockReq = {
params: {namespace: 'test-namespace'},
user: {hasAuth: true, email: '[email protected]'},
};

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: '[email protected]'},
};

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();
});
});
});
5 changes: 3 additions & 2 deletions components/centraldashboard-angular/backend/app/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
71 changes: 71 additions & 0 deletions components/centraldashboard/app/api.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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.
Expand Down Expand Up @@ -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));
Expand Down
Loading
Loading