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
29 changes: 29 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ import {
markLegacyApi,
rejectLegacyAgentApi,
} from './middleware/legacyAgentApi';
import {
bindWorkspaceRouteContext,
requireWorkspaceRouteContext,
} from './middleware/workspaceRouteContext';

// Import cleanup utilities
import { TraceProcessorFactory, killOrphanProcessors } from './services/workingTraceProcessor';
Expand All @@ -70,6 +74,11 @@ import { getPortPool, resetPortPool } from './services/portPool';
const app = express();
const PORT = serverConfig.port;
const NODE_ENV = serverConfig.nodeEnv;
const workspaceRouteContextMiddleware: express.RequestHandler[] = [
bindWorkspaceRouteContext,
authenticate,
requireWorkspaceRouteContext,
];

// Fail fast for trace-analysis-specific credentials when strict startup validation is enabled.
assertTraceAnalysisConfiguredForStartup();
Expand Down Expand Up @@ -150,6 +159,26 @@ app.get('/debug', (req, res) => {
app.use('/api/sql', sqlRoutes);
app.use('/api/auth', enterpriseAuthRoutes);
app.use('/api/auth', enterpriseApiKeyRoutes);
app.use(
'/api/workspaces/:workspaceId/traces',
...workspaceRouteContextMiddleware,
simpleTraceRoutes,
);
app.use(
'/api/workspaces/:workspaceId/reports',
...workspaceRouteContextMiddleware,
reportRoutes,
);
app.use(
'/api/workspaces/:workspaceId/agent',
...workspaceRouteContextMiddleware,
agentRoutes,
);
app.use(
'/api/workspaces/:workspaceId/providers',
...workspaceRouteContextMiddleware,
providerRoutes,
);
app.use(
'/api/traces',
markLegacyApi(
Expand Down
52 changes: 52 additions & 0 deletions backend/src/middleware/workspaceRouteContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// 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 type { NextFunction, Request, Response } from 'express';
import { getRequestContext } from './auth';
import { sendResourceNotFound } from '../services/resourceOwnership';

type WorkspaceScopedRequest = Request & {
workspaceRouteContext?: {
workspaceId: string;
};
};

function sanitizeWorkspaceId(value: unknown): string {
if (typeof value !== 'string') return '';
return value.trim().replace(/[^a-zA-Z0-9._:-]/g, '').slice(0, 128);
}

export function bindWorkspaceRouteContext(req: Request, res: Response, next: NextFunction): void {
const workspaceId = sanitizeWorkspaceId(req.params.workspaceId);
if (!workspaceId) {
res.status(400).json({
success: false,
error: 'workspaceId is required',
});
return;
}

(req as WorkspaceScopedRequest).workspaceRouteContext = { workspaceId };
req.headers['x-workspace-id'] = workspaceId;
next();
}

export function requireWorkspaceRouteContext(req: Request, res: Response, next: NextFunction): void {
const expectedWorkspaceId = (req as WorkspaceScopedRequest).workspaceRouteContext?.workspaceId;
if (!expectedWorkspaceId) {
res.status(400).json({
success: false,
error: 'workspace route context is missing',
});
return;
}

const context = getRequestContext(req);
if (!context || context.workspaceId !== expectedWorkspaceId) {
sendResourceNotFound(res);
return;
}

next();
}
196 changes: 196 additions & 0 deletions backend/src/routes/__tests__/workspaceResourceRoutes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
// 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, beforeEach, describe, expect, it } from '@jest/globals';
import crypto from 'crypto';
import express from 'express';
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import request from 'supertest';
import { authenticate } from '../../middleware/auth';
import {
bindWorkspaceRouteContext,
requireWorkspaceRouteContext,
} from '../../middleware/workspaceRouteContext';
import agentRoutes from '../agentRoutes';
import providerRoutes from '../providerRoutes';
import reportRoutes, { reportStore } from '../reportRoutes';
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 = 'workspace-route-secret';
const API_USER_ID = `api-key-${crypto.createHash('sha256').update(API_KEY).digest('hex').slice(0, 8)}`;

let uploadDir: string;

function makeWorkspaceApp(): express.Express {
const app = express();
const workspaceMiddlewares = [
bindWorkspaceRouteContext,
authenticate,
requireWorkspaceRouteContext,
];
app.use(express.json());
app.use('/api/workspaces/:workspaceId/traces', ...workspaceMiddlewares, traceRoutes);
app.use('/api/workspaces/:workspaceId/reports', ...workspaceMiddlewares, reportRoutes);
app.use('/api/workspaces/:workspaceId/agent', ...workspaceMiddlewares, agentRoutes);
app.use('/api/workspaces/:workspaceId/providers', ...workspaceMiddlewares, providerRoutes);
return app;
}

function authHeaders(req: request.Test, workspaceId = 'workspace-a'): request.Test {
return req
.set('Authorization', `Bearer ${API_KEY}`)
.set('x-tenant-id', 'tenant-a')
.set('x-workspace-id', workspaceId);
}

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

async function writeTraceMetadata(id: string, workspaceId: string): Promise<void> {
const tracesDir = path.join(uploadDir, 'traces');
await fs.mkdir(tracesDir, { recursive: true });
const tracePath = path.join(tracesDir, `${id}.trace`);
await fs.writeFile(tracePath, `trace-${id}`);
await fs.writeFile(
path.join(tracesDir, `${id}.json`),
JSON.stringify({
id,
filename: `${id}.trace`,
size: 16,
uploadedAt: new Date().toISOString(),
status: 'ready',
path: tracePath,
tenantId: 'tenant-a',
workspaceId,
userId: API_USER_ID,
}, null, 2),
);
}

beforeEach(async () => {
uploadDir = await fs.mkdtemp(path.join(os.tmpdir(), 'smartperfetto-workspace-routes-'));
process.env.UPLOAD_DIR = uploadDir;
process.env.SMARTPERFETTO_API_KEY = API_KEY;
reportStore.clear();
});

afterEach(async () => {
reportStore.clear();
if (originalApiKey === undefined) {
delete process.env.SMARTPERFETTO_API_KEY;
} else {
process.env.SMARTPERFETTO_API_KEY = originalApiKey;
}
if (originalUploadDir === undefined) {
delete process.env.UPLOAD_DIR;
} 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 });
});

describe('workspace resource routes', () => {
it('binds trace list ownership to the workspace path without legacy headers', async () => {
await writeTraceMetadata('trace-a', 'workspace-a');
await writeTraceMetadata('trace-b', 'workspace-b');
const app = makeWorkspaceApp();

const res = await authHeaders(
request(app).get('/api/workspaces/workspace-b/traces'),
'workspace-a',
);

expect(res.status).toBe(200);
expect(res.headers.deprecation).toBeUndefined();
expect(res.body.traces.map((trace: any) => trace.id)).toEqual(['trace-b']);
});

it('rejects trusted SSO requests whose selected workspace differs from the workspace path', async () => {
process.env.SMARTPERFETTO_SSO_TRUSTED_HEADERS = 'true';
const app = makeWorkspaceApp();

const res = await trustedSsoHeaders(
request(app).get('/api/workspaces/workspace-b/traces'),
'workspace-a',
);

expect(res.status).toBe(404);
expect(res.body.error).toBe('Resource not found');
});

it('serves reports through workspace-scoped paths without legacy headers', async () => {
reportStore.set('report-b', {
html: '<html><body>workspace b report</body></html>',
generatedAt: Date.now(),
sessionId: 'session-b',
tenantId: 'tenant-a',
workspaceId: 'workspace-b',
userId: API_USER_ID,
});
const app = makeWorkspaceApp();

const res = await authHeaders(
request(app).get('/api/workspaces/workspace-b/reports/report-b'),
'workspace-a',
);

expect(res.status).toBe(200);
expect(res.headers.deprecation).toBeUndefined();
expect(res.text).toContain('workspace b report');
});

it('mounts provider and agent aliases under the workspace resource root', async () => {
const app = makeWorkspaceApp();

const providerRes = await authHeaders(
request(app).get('/api/workspaces/workspace-b/providers/templates'),
'workspace-a',
);
expect(providerRes.status).toBe(200);
expect(providerRes.headers.deprecation).toBeUndefined();
expect(providerRes.body.success).toBe(true);

const runRes = await authHeaders(
request(app)
.post('/api/workspaces/workspace-b/agent/sessions/session-b/runs')
.send({ query: '分析 trace' }),
'workspace-a',
);
expect(runRes.status).toBe(400);
expect(runRes.body.error).toBe('traceId is required');

const respondRes = await authHeaders(
request(app)
.post('/api/workspaces/workspace-b/agent/sessions/missing-session/respond')
.send({ action: 'continue' }),
'workspace-a',
);
expect(respondRes.status).toBe(404);
expect(respondRes.body.error).toBe('Session not found');

const streamRes = await authHeaders(
request(app).get('/api/workspaces/workspace-b/agent/runs/missing-run/stream'),
'workspace-a',
);
expect(streamRes.status).toBe(404);
expect(streamRes.body.error).toBe('Run not found');
});
});
Loading