diff --git a/backend/src/index.ts b/backend/src/index.ts index aa573bb0..51d883f6 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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'; @@ -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(); @@ -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( diff --git a/backend/src/middleware/workspaceRouteContext.ts b/backend/src/middleware/workspaceRouteContext.ts new file mode 100644 index 00000000..9225dac9 --- /dev/null +++ b/backend/src/middleware/workspaceRouteContext.ts @@ -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(); +} diff --git a/backend/src/routes/__tests__/workspaceResourceRoutes.test.ts b/backend/src/routes/__tests__/workspaceResourceRoutes.test.ts new file mode 100644 index 00000000..27b6fe27 --- /dev/null +++ b/backend/src/routes/__tests__/workspaceResourceRoutes.test.ts @@ -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 { + 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: 'workspace b report', + 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'); + }); +}); diff --git a/backend/src/routes/agentRoutes.ts b/backend/src/routes/agentRoutes.ts index 6876ccaf..efba31cf 100644 --- a/backend/src/routes/agentRoutes.ts +++ b/backend/src/routes/agentRoutes.ts @@ -408,6 +408,22 @@ function getAuthorizedSession(req: express.Request, res: express.Response, sessi return session; } +function getAuthorizedSessionByRunId( + req: express.Request, + res: express.Response, + runId: string, +): AnalysisSession | null { + const context = requireRequestContext(req); + for (const [, session] of assistantAppService.entries()) { + const isRequestedRun = session.activeRun?.runId === runId || session.lastRun?.runId === runId; + if (isRequestedRun && isOwnedByContext(session, context)) { + return session; + } + } + sendResourceNotFound(res, 'Run not found'); + return null; +} + function isResolvedSessionAccessible(req: express.Request, resolved: ResolvedSessionContext): boolean { return isOwnedByContext(resolved, requireRequestContext(req)); } @@ -742,40 +758,59 @@ function isDedicatedSceneReplayRequest(query: string): boolean { * } * } */ -router.post('/analyze', async (req, res) => { +async function handleAnalyzeRequest( + req: express.Request, + res: express.Response, + requestedSessionIdOverride?: string, +): Promise { try { const requestId = getRequestId(req); const requestContext = requireRequestContext(req); - const { traceId, query, sessionId: requestedSessionId, options = {}, selectionContext: rawSelectionContext, referenceTraceId, traceContext: rawTraceContext, providerId } = req.body; + const { + traceId, + query, + sessionId: bodyRequestedSessionId, + options = {}, + selectionContext: rawSelectionContext, + referenceTraceId, + traceContext: rawTraceContext, + providerId, + } = req.body; + const requestedSessionId = requestedSessionIdOverride || bodyRequestedSessionId; if (!hasRbacPermission(requestContext, 'agent:run')) { - return sendForbidden(res, 'Starting analysis requires agent:run permission'); + sendForbidden(res, 'Starting analysis requires agent:run permission'); + return; } if (!traceId) { - return res.status(400).json({ + res.status(400).json({ success: false, error: 'traceId is required', }); + return; } if (!query) { - return res.status(400).json({ + res.status(400).json({ success: false, error: 'query is required', }); + return; } if (isDedicatedSceneReplayRequest(query)) { - return res.status(400).json({ + res.status(400).json({ success: false, code: 'SCENE_REPLAY_SEPARATED', error: '场景还原已独立为专用功能', hint: '请使用 /scene 命令(前端)或 POST /api/agent/v1/scene-reconstruct(后端)', }); + return; } if (requestedSessionId && !requestedSessionIsVisible(requestedSessionId, requestContext)) { - return sendResourceNotFound(res, 'Session not found'); + sendResourceNotFound(res, 'Session not found'); + return; } // Validate selectionContext — strip invalid payloads silently instead of rejecting @@ -797,34 +832,37 @@ router.post('/analyze', async (req, res) => { } const trace = await traceProcessorService.getOrLoadTrace(traceId); if (!trace) { - return res.status(404).json({ + res.status(404).json({ success: false, error: 'Trace not found in backend', hint: 'Please upload the trace to the backend first', code: 'TRACE_NOT_UPLOADED', }); + return; } // Comparison mode: validate reference trace if provided if (referenceTraceId) { if (referenceTraceId === traceId) { - return res.status(400).json({ + res.status(400).json({ success: false, error: 'referenceTraceId must be different from traceId', code: 'SAME_TRACE_COMPARISON', }); + return; } if (!await ensureTraceAccessible(req, res, referenceTraceId)) { return; } const refTrace = await traceProcessorService.getOrLoadTrace(referenceTraceId); if (!refTrace) { - return res.status(404).json({ + res.status(404).json({ success: false, error: 'Reference trace not found in backend', hint: 'Please upload the reference trace to the backend first', code: 'REFERENCE_TRACE_NOT_UPLOADED', }); + return; } console.log(`[AgentRoutes] Comparison mode: current=${traceId}, reference=${referenceTraceId}`); } @@ -856,16 +894,18 @@ router.post('/analyze', async (req, res) => { if (isNewSession) { assignSessionOwner(preparedSession, requestContext); } else if (!isOwnedByContext(preparedSession, requestContext)) { - return sendResourceNotFound(res, 'Session not found'); + sendResourceNotFound(res, 'Session not found'); + return; } } catch (error: any) { if (error instanceof AnalyzeSessionPreparationError) { - return res.status(error.httpStatus).json({ + res.status(error.httpStatus).json({ success: false, error: error.message, code: error.code, ...(error.hint ? { hint: error.hint } : {}), }); + return; } throw error; } @@ -944,6 +984,14 @@ router.post('/analyze', async (req, res) => { error: error.message || 'Agent analysis failed', }); } +} + +router.post('/analyze', async (req, res) => { + await handleAnalyzeRequest(req, res); +}); + +router.post('/sessions/:sessionId/runs', async (req, res) => { + await handleAnalyzeRequest(req, res, req.params.sessionId); }); /** @@ -964,9 +1012,7 @@ router.post('/analyze', async (req, res) => { * - error: Error occurred * - end: Stream ended */ -router.get('/:sessionId/stream', (req, res) => { - const { sessionId } = req.params; - +function handleSessionStream(req: express.Request, res: express.Response, sessionId: string): void { const session = getAuthorizedSession(req, res, sessionId); if (!session) return; @@ -1070,6 +1116,16 @@ router.get('/:sessionId/stream', (req, res) => { }); streamProjector.bindKeepAlive(req, res); +} + +router.get('/:sessionId/stream', (req, res) => { + handleSessionStream(req, res, req.params.sessionId); +}); + +router.get('/runs/:runId/stream', (req, res) => { + const session = getAuthorizedSessionByRunId(req, res, req.params.runId); + if (!session) return; + handleSessionStream(req, res, session.sessionId); }); /** @@ -1355,8 +1411,7 @@ router.post('/:sessionId/feedback', async (req, res) => { * Note: AgentRuntime currently does not pause for user input in v2; * this endpoint mainly exists for API compatibility and future multi-turn UX. */ -router.post('/:sessionId/respond', async (req, res) => { - const { sessionId } = req.params; +function handleSessionRespond(req: express.Request, res: express.Response, sessionId: string): void { const session = getAuthorizedSession(req, res, sessionId); if (!session) return; @@ -1364,28 +1419,39 @@ router.post('/:sessionId/respond', async (req, res) => { const allowedActions = new Set(['continue', 'abort']); if (!action || typeof action !== 'string' || !allowedActions.has(action)) { - return res.status(400).json({ + res.status(400).json({ success: false, error: `Invalid action: ${String(action)}. Allowed: continue, abort`, }); + return; } if (action === 'abort') { session.status = 'failed'; session.error = 'Aborted by user'; - return res.json({ success: true, sessionId, status: session.status }); + res.json({ success: true, sessionId, status: session.status }); + return; } // continue if (session.status !== 'awaiting_user') { - return res.status(400).json({ + res.status(400).json({ success: false, error: `Session is not awaiting user input (current status: ${session.status})`, }); + return; } session.status = 'running'; - return res.json({ success: true, sessionId, status: session.status }); + res.json({ success: true, sessionId, status: session.status }); +} + +router.post('/:sessionId/respond', (req, res) => { + handleSessionRespond(req, res, req.params.sessionId); +}); + +router.post('/sessions/:sessionId/respond', (req, res) => { + handleSessionRespond(req, res, req.params.sessionId); }); // ============================================================================= diff --git a/docs/features/enterprise-multi-tenant/README.md b/docs/features/enterprise-multi-tenant/README.md index 60c8a47f..8c788f2e 100644 --- a/docs/features/enterprise-multi-tenant/README.md +++ b/docs/features/enterprise-multi-tenant/README.md @@ -34,7 +34,7 @@ - [x] 2.3 API key 管理(创建 / 撤销 / scope / 过期 / 审计) - [x] 2.4 Membership / Role / RBAC 权限矩阵(§8.2)+ owner guard 全 route 覆盖 - [x] 2.5 旧 API 兼容 wrapper:返回 `Deprecation: true` + `Sunset` header;统一走 RequestContext -- [ ] 2.6 Resource-oriented API 切到 `/api/workspaces/:workspaceId/*`(§8.3) +- [x] 2.6 Resource-oriented API 切到 `/api/workspaces/:workspaceId/*`(§8.3) - [ ] 2.7 前端 workspace selection UI + workspace/window 上下文持久化分层(§9.2 表格) - [ ] 2.8 单元测试覆盖:RequestContext 解析 / RBAC / owner guard / 旧路径包装