Skip to content
Closed
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
55 changes: 55 additions & 0 deletions backend/src/routes/__tests__/agentRoutesRbac.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2024-2026 Gracker (Chris)
// This file is part of SmartPerfetto. See LICENSE for details.

import { afterEach, describe, expect, it } from '@jest/globals';
import express from 'express';
import request from 'supertest';
import agentRoutes from '../agentRoutes';

const originalApiKey = process.env.SMARTPERFETTO_API_KEY;
const originalSsoTrustedHeaders = process.env.SMARTPERFETTO_SSO_TRUSTED_HEADERS;

function makeApp(): express.Express {
const app = express();
app.use(express.json());
app.use('/api/agent/v1', agentRoutes);
return app;
}

function viewerHeaders(req: request.Test): request.Test {
return req
.set('X-SmartPerfetto-SSO-User-Id', 'viewer-user')
.set('X-SmartPerfetto-SSO-Email', 'viewer@example.test')
.set('X-SmartPerfetto-SSO-Tenant-Id', 'tenant-a')
.set('X-SmartPerfetto-SSO-Workspace-Id', 'workspace-a')
.set('X-SmartPerfetto-SSO-Roles', 'viewer')
.set('X-SmartPerfetto-SSO-Scopes', 'trace:read,report:read');
}

afterEach(() => {
if (originalApiKey === undefined) {
delete process.env.SMARTPERFETTO_API_KEY;
} else {
process.env.SMARTPERFETTO_API_KEY = originalApiKey;
}
if (originalSsoTrustedHeaders === undefined) {
delete process.env.SMARTPERFETTO_SSO_TRUSTED_HEADERS;
} else {
process.env.SMARTPERFETTO_SSO_TRUSTED_HEADERS = originalSsoTrustedHeaders;
}
});

describe('agent route RBAC', () => {
it('rejects viewer analyze requests before trace access is evaluated', async () => {
delete process.env.SMARTPERFETTO_API_KEY;
process.env.SMARTPERFETTO_SSO_TRUSTED_HEADERS = 'true';

const res = await viewerHeaders(request(makeApp()).post('/api/agent/v1/analyze'))
.send({ traceId: 'trace-a', query: 'analyze this trace' });

expect(res.status).toBe(403);
expect(res.body.error).toBe('Forbidden');
expect(res.body.details).toContain('agent:run');
});
});
90 changes: 90 additions & 0 deletions backend/src/routes/__tests__/ownerGuardRoutes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import traceRoutes from '../simpleTraceRoutes';

const originalApiKey = process.env.SMARTPERFETTO_API_KEY;
const originalUploadDir = process.env.UPLOAD_DIR;
const originalSsoTrustedHeaders = process.env.SMARTPERFETTO_SSO_TRUSTED_HEADERS;
const API_KEY = 'owner-test-secret';
const API_USER_ID = `api-key-${crypto.createHash('sha256').update(API_KEY).digest('hex').slice(0, 8)}`;

Expand All @@ -29,6 +30,21 @@ function authHeaders(req: request.Test, tenantId = 'tenant-a', workspaceId = 'wo
.set('x-workspace-id', workspaceId);
}

function ssoHeaders(
req: request.Test,
userId: string,
role: string,
scopes = 'trace:read,report:read',
): request.Test {
return req
.set('X-SmartPerfetto-SSO-User-Id', userId)
.set('X-SmartPerfetto-SSO-Email', `${userId}@example.test`)
.set('X-SmartPerfetto-SSO-Tenant-Id', 'tenant-a')
.set('X-SmartPerfetto-SSO-Workspace-Id', 'workspace-a')
.set('X-SmartPerfetto-SSO-Roles', role)
.set('X-SmartPerfetto-SSO-Scopes', scopes);
}

function makeResourceApp(): express.Express {
const app = express();
app.use(express.json());
Expand Down Expand Up @@ -80,6 +96,11 @@ afterEach(async () => {
} else {
process.env.UPLOAD_DIR = originalUploadDir;
}
if (originalSsoTrustedHeaders === undefined) {
delete process.env.SMARTPERFETTO_SSO_TRUSTED_HEADERS;
} else {
process.env.SMARTPERFETTO_SSO_TRUSTED_HEADERS = originalSsoTrustedHeaders;
}
await fs.rm(uploadDir, { recursive: true, force: true });
});

Expand Down Expand Up @@ -130,6 +151,53 @@ describe('owner guard for trace and report routes', () => {
expect(res.body.success).toBe(false);
});

it('allows viewer to read same-workspace traces but blocks trace writes and deletes', async () => {
process.env.SMARTPERFETTO_SSO_TRUSTED_HEADERS = 'true';
await writeTraceMetadata('peer-trace', {
tenantId: 'tenant-a',
workspaceId: 'workspace-a',
userId: 'peer-user',
});
await writeTraceMetadata('viewer-trace', {
tenantId: 'tenant-a',
workspaceId: 'workspace-a',
userId: 'viewer-user',
});
const app = makeResourceApp();

const readRes = await ssoHeaders(request(app).get('/api/traces/peer-trace'), 'viewer-user', 'viewer');
expect(readRes.status).toBe(200);
expect(readRes.body.trace.id).toBe('peer-trace');

const writeRes = await ssoHeaders(
request(app).post('/api/traces/register-rpc').send({ port: 12345, traceName: 'Viewer RPC' }),
'viewer-user',
'viewer',
);
expect(writeRes.status).toBe(403);

const deleteRes = await ssoHeaders(request(app).delete('/api/traces/viewer-trace'), 'viewer-user', 'viewer');
expect(deleteRes.status).toBe(403);
});

it('allows workspace admin to delete another user trace in the same workspace', async () => {
process.env.SMARTPERFETTO_SSO_TRUSTED_HEADERS = 'true';
await writeTraceMetadata('peer-owned-trace', {
tenantId: 'tenant-a',
workspaceId: 'workspace-a',
userId: 'peer-user',
});
const app = makeResourceApp();

const deleteRes = await ssoHeaders(
request(app).delete('/api/traces/peer-owned-trace'),
'admin-user',
'workspace_admin',
'trace:read,trace:write,trace:delete:any',
);
expect(deleteRes.status).toBe(200);
});

it('guards persisted report access and delete by owner fields', async () => {
reportStore.set('own-report', {
html: '<html><body>own report</body></html>',
Expand Down Expand Up @@ -161,6 +229,28 @@ describe('owner guard for trace and report routes', () => {
expect(otherDelete.status).toBe(404);
expect(reportStore.has('other-report')).toBe(true);
});

it('allows workspace admin to delete another user report in the same workspace', async () => {
process.env.SMARTPERFETTO_SSO_TRUSTED_HEADERS = 'true';
reportStore.set('peer-report', {
html: '<html><body>peer report</body></html>',
generatedAt: Date.now(),
sessionId: 'peer-session',
tenantId: 'tenant-a',
workspaceId: 'workspace-a',
userId: 'peer-user',
});
const app = makeResourceApp();

const deleteRes = await ssoHeaders(
request(app).delete('/api/reports/peer-report'),
'admin-user',
'workspace_admin',
'report:read,report:delete',
);
expect(deleteRes.status).toBe(200);
expect(reportStore.has('peer-report')).toBe(false);
});
});

describe('owner guard for agent session routes', () => {
Expand Down
4 changes: 4 additions & 0 deletions backend/src/routes/agentRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
sendResourceNotFound,
type ResourceOwnerFields,
} from '../services/resourceOwnership';
import { hasRbacPermission, sendForbidden } from '../services/rbac';
import { readTraceMetadataForContext } from '../services/traceMetadataStore';
import {
sessionContextManager,
Expand Down Expand Up @@ -746,6 +747,9 @@ router.post('/analyze', async (req, res) => {
const requestId = getRequestId(req);
const requestContext = requireRequestContext(req);
const { traceId, query, sessionId: requestedSessionId, options = {}, selectionContext: rawSelectionContext, referenceTraceId, traceContext: rawTraceContext, providerId } = req.body;
if (!hasRbacPermission(requestContext, 'agent:run')) {
return sendForbidden(res, 'Starting analysis requires agent:run permission');
}

if (!traceId) {
return res.status(400).json({
Expand Down
17 changes: 13 additions & 4 deletions backend/src/routes/reportRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,15 @@ import { attachRequestContext, requireRequestContext } from '../middleware/auth'
import { REPORT_CAUSAL_MAP_CSS, REPORT_CAUSAL_MAP_SCRIPT } from '../services/reportCausalMapAssets';
import { localize, parseOutputLanguage } from '../agentv3/outputLanguage';
import {
isOwnedByContext,
sendResourceNotFound,
type ResourceOwnerFields,
} from '../services/resourceOwnership';
import {
canDeleteReportResource,
canReadReportResource,
sendForbidden,
sharesWorkspaceWithContext,
} from '../services/rbac';

const router = express.Router();

Expand Down Expand Up @@ -120,7 +125,7 @@ function loadReportFromDisk(reportId: string): PersistedReport | null {
function getReportForContext(reportId: string, req: express.Request): PersistedReport | null {
const context = requireRequestContext(req);
const report = reportStore.get(reportId) || loadReportFromDisk(reportId);
if (!report || !isOwnedByContext(report, context)) {
if (!report || !canReadReportResource(report, context)) {
return null;
}
return report;
Expand Down Expand Up @@ -254,10 +259,14 @@ router.delete('/:reportId', (req, res) => {
try {
const { reportId } = req.params;

const report = getReportForContext(reportId, req);
if (!report) {
const context = requireRequestContext(req);
const report = reportStore.get(reportId) || loadReportFromDisk(reportId);
if (!report || !sharesWorkspaceWithContext(report, context)) {
return sendResourceNotFound(res, 'Report not found');
}
if (!canDeleteReportResource(report, context)) {
return sendForbidden(res, 'Deleting this report requires report delete permission');
}

const deleted = reportStore.delete(reportId);

Expand Down
Loading