diff --git a/backend/src/routes/__tests__/agentRoutesRbac.test.ts b/backend/src/routes/__tests__/agentRoutesRbac.test.ts new file mode 100644 index 00000000..1c4c4eb3 --- /dev/null +++ b/backend/src/routes/__tests__/agentRoutesRbac.test.ts @@ -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'); + }); +}); diff --git a/backend/src/routes/__tests__/ownerGuardRoutes.test.ts b/backend/src/routes/__tests__/ownerGuardRoutes.test.ts index 1c73b47c..d450d428 100644 --- a/backend/src/routes/__tests__/ownerGuardRoutes.test.ts +++ b/backend/src/routes/__tests__/ownerGuardRoutes.test.ts @@ -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)}`; @@ -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()); @@ -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 }); }); @@ -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: 'own report', @@ -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: 'peer report', + 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', () => { diff --git a/backend/src/routes/agentRoutes.ts b/backend/src/routes/agentRoutes.ts index 58e90d12..6876ccaf 100644 --- a/backend/src/routes/agentRoutes.ts +++ b/backend/src/routes/agentRoutes.ts @@ -29,6 +29,7 @@ import { sendResourceNotFound, type ResourceOwnerFields, } from '../services/resourceOwnership'; +import { hasRbacPermission, sendForbidden } from '../services/rbac'; import { readTraceMetadataForContext } from '../services/traceMetadataStore'; import { sessionContextManager, @@ -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({ diff --git a/backend/src/routes/reportRoutes.ts b/backend/src/routes/reportRoutes.ts index 35acabe5..d31c9f7b 100644 --- a/backend/src/routes/reportRoutes.ts +++ b/backend/src/routes/reportRoutes.ts @@ -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(); @@ -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; @@ -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); diff --git a/backend/src/routes/simpleTraceRoutes.ts b/backend/src/routes/simpleTraceRoutes.ts index aaccca96..5a407450 100644 --- a/backend/src/routes/simpleTraceRoutes.ts +++ b/backend/src/routes/simpleTraceRoutes.ts @@ -2,7 +2,7 @@ // Copyright (C) 2024-2026 Gracker (Chris) // This file is part of SmartPerfetto. See LICENSE for details. -import { Router } from 'express'; +import { Router, type NextFunction, type Request, type Response } from 'express'; import multer from 'multer'; import path from 'path'; import fs from 'fs/promises'; @@ -17,16 +17,35 @@ import { getTraceFilePath, getTracesDir, listTraceMetadata, + readTraceMetadata, readTraceMetadataForContext, type TraceMetadata, writeTraceMetadata, } from '../services/traceMetadataStore'; import { isPrivilegedRequestContext, sendResourceNotFound } from '../services/resourceOwnership'; +import { + canDeleteTraceResource, + canDownloadTraceResource, + hasRbacPermission, + sendForbidden, + sharesWorkspaceWithContext, +} from '../services/rbac'; const router = Router(); const MAX_UPLOAD_BYTES = 500 * 1024 * 1024; const URL_UPLOAD_TIMEOUT_MS = 300000; +function requireTracePermission(permission: 'trace:read' | 'trace:write', details: string) { + return (req: Request, res: Response, next: NextFunction): void => { + const context = requireRequestContext(req); + if (!hasRbacPermission(context, permission)) { + sendForbidden(res, details); + return; + } + next(); + }; +} + async function finalizeTraceUpload( traceId: string, filename: string, @@ -126,60 +145,68 @@ const upload = multer({ }); // POST /api/traces/upload - Simple upload with RequestContext ownership -router.post('/upload', upload.single('file'), async (req, res) => { - try { - const context = requireRequestContext(req); - if (!req.file) { - return res.status(400).json({ - error: 'No file uploaded' - }); - } +router.post( + '/upload', + requireTracePermission('trace:write', 'Uploading traces requires trace:write permission'), + upload.single('file'), + async (req, res) => { + try { + const context = requireRequestContext(req); + if (!req.file) { + return res.status(400).json({ + error: 'No file uploaded' + }); + } - const file = req.file; + const file = req.file; - // Store trace info (in a real app, this would go to a database) - const tracesDir = getTracesDir(); - await fs.mkdir(tracesDir, { recursive: true }); + // Store trace info (in a real app, this would go to a database) + const tracesDir = getTracesDir(); + await fs.mkdir(tracesDir, { recursive: true }); - // Generate trace ID upfront for consistency - const traceId = uuidv4(); + // Generate trace ID upfront for consistency + const traceId = uuidv4(); - // Move file to traces directory with proper name - const finalPath = path.join(tracesDir, `${traceId}.trace`); - await fs.rename(file.path, finalPath); + // Move file to traces directory with proper name + const finalPath = path.join(tracesDir, `${traceId}.trace`); + await fs.rename(file.path, finalPath); - console.log(`File uploaded successfully: ${file.originalname} -> ${traceId}`); + console.log(`File uploaded successfully: ${file.originalname} -> ${traceId}`); - // Get trace status and processor port from service - const traceInfo = await finalizeTraceUpload(traceId, file.originalname, file.size, finalPath, context); + // Get trace status and processor port from service + const traceInfo = await finalizeTraceUpload(traceId, file.originalname, file.size, finalPath, context); - res.json({ - success: true, - trace: { - id: traceId, - filename: file.originalname, - size: file.size, - uploadedAt: traceInfo?.uploadTime || new Date().toISOString(), - status: traceInfo?.status || 'ready', - // Port for HTTP RPC mode - frontend can connect to trace_processor directly - port: traceInfo?.port, - processorStatus: traceInfo?.processor?.status, - } - }); + res.json({ + success: true, + trace: { + id: traceId, + filename: file.originalname, + size: file.size, + uploadedAt: traceInfo?.uploadTime || new Date().toISOString(), + status: traceInfo?.status || 'ready', + // Port for HTTP RPC mode - frontend can connect to trace_processor directly + port: traceInfo?.port, + processorStatus: traceInfo?.processor?.status, + } + }); - } catch (error: any) { - console.error('Upload error:', error); - res.status(500).json({ - error: 'Upload failed', - details: error.message - }); - } -}); + } catch (error: any) { + console.error('Upload error:', error); + res.status(500).json({ + error: 'Upload failed', + details: error.message + }); + } + }, +); // POST /api/traces/upload-url - Fetch a remote trace from the backend side. router.post('/upload-url', async (req, res) => { try { const context = requireRequestContext(req); + if (!hasRbacPermission(context, 'trace:write')) { + return sendForbidden(res, 'Uploading traces requires trace:write permission'); + } const rawUrl = typeof req.body?.url === 'string' ? req.body.url : ''; if (!rawUrl) { return res.status(400).json({ @@ -267,6 +294,9 @@ router.post('/upload-url', async (req, res) => { router.get('/', async (req, res) => { try { const context = requireRequestContext(req); + if (!hasRbacPermission(context, 'trace:read')) { + return sendForbidden(res, 'Listing traces requires trace:read permission'); + } const ownedTraces: TraceMetadata[] = []; for (const trace of await listTraceMetadata()) { const owned = await readTraceMetadataForContext(trace.id, context); @@ -291,6 +321,9 @@ router.get('/', async (req, res) => { router.get('/stats', async (req, res) => { try { const context = requireRequestContext(req); + if (!hasRbacPermission(context, 'trace:read')) { + return sendForbidden(res, 'Trace stats require trace:read permission'); + } const ownedTraceIds = new Set(); for (const metadata of await listTraceMetadata()) { if (await readTraceMetadataForContext(metadata.id, context)) { @@ -380,6 +413,9 @@ router.post('/cleanup', async (req, res) => { router.post('/register-rpc', async (req, res) => { try { const context = requireRequestContext(req); + if (!hasRbacPermission(context, 'trace:write')) { + return sendForbidden(res, 'Registering traces requires trace:write permission'); + } const { port, traceName } = req.body; if (!port) { @@ -471,13 +507,22 @@ router.delete('/:id', async (req, res) => { try { const context = requireRequestContext(req); const { id } = req.params; - const metadata = await readTraceMetadataForContext(id, context); + const metadata = await readTraceMetadata(id); if (!metadata) { return res.status(404).json({ error: 'Trace not found', id }); } + if (!sharesWorkspaceWithContext(metadata, context)) { + return res.status(404).json({ + error: 'Trace not found', + id + }); + } + if (!canDeleteTraceResource(metadata, context)) { + return sendForbidden(res, 'Deleting this trace requires trace delete permission'); + } const tracesDir = getTracesDir(); console.log(`[Traces] Deleting trace ${id} and cleaning up resources...`); @@ -533,6 +578,9 @@ router.get('/:id/file', async (req, res) => { id }); } + if (!canDownloadTraceResource(metadata, context)) { + return sendForbidden(res, 'Downloading traces requires trace download permission'); + } const tracePath = metadata.path || getTraceFilePath(id); if (!tracePath) { return res.status(404).json({ diff --git a/backend/src/services/__tests__/rbac.test.ts b/backend/src/services/__tests__/rbac.test.ts new file mode 100644 index 00000000..20c34b60 --- /dev/null +++ b/backend/src/services/__tests__/rbac.test.ts @@ -0,0 +1,77 @@ +// 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 { RequestContext } from '../../middleware/auth'; +import { + canDeleteTraceResource, + canReadTraceResource, + hasRbacPermission, + sharesWorkspaceWithContext, +} from '../rbac'; + +function context(role: string, scopes: string[] = []): RequestContext { + return { + tenantId: 'tenant-a', + workspaceId: 'workspace-a', + userId: `${role}-user`, + authType: 'sso', + roles: [role], + scopes, + requestId: `req-${role}`, + }; +} + +describe('enterprise RBAC matrix', () => { + test('maps viewer, analyst, workspace admin, and org admin role permissions', () => { + expect(hasRbacPermission(context('viewer'), 'trace:read')).toBe(true); + expect(hasRbacPermission(context('viewer'), 'trace:write')).toBe(false); + expect(hasRbacPermission(context('viewer'), 'agent:run')).toBe(false); + + expect(hasRbacPermission(context('analyst'), 'trace:write')).toBe(true); + expect(hasRbacPermission(context('analyst'), 'agent:run')).toBe(true); + expect(hasRbacPermission(context('analyst'), 'trace:delete_any')).toBe(false); + + expect(hasRbacPermission(context('workspace_admin'), 'trace:delete_any')).toBe(true); + expect(hasRbacPermission(context('workspace_admin'), 'provider:manage_workspace')).toBe(true); + expect(hasRbacPermission(context('workspace_admin'), 'provider:manage_org')).toBe(false); + + expect(hasRbacPermission(context('org_admin'), 'provider:manage_org')).toBe(true); + }); + + test('lets explicit scopes authorize API key contexts without granting unrelated permissions', () => { + const apiKeyContext: RequestContext = { + tenantId: 'tenant-a', + workspaceId: 'workspace-a', + userId: 'api-key-owner', + authType: 'api_key', + roles: ['api_key'], + scopes: ['trace:read', 'agent:run'], + requestId: 'req-api-key', + }; + + expect(hasRbacPermission(apiKeyContext, 'trace:read')).toBe(true); + expect(hasRbacPermission(apiKeyContext, 'agent:run')).toBe(true); + expect(hasRbacPermission(apiKeyContext, 'trace:write')).toBe(false); + }); + + test('combines owner guard with role permissions for workspace resources', () => { + const peerTrace = { + tenantId: 'tenant-a', + workspaceId: 'workspace-a', + userId: 'peer-user', + }; + const analyst = context('analyst'); + const admin = context('workspace_admin'); + + expect(sharesWorkspaceWithContext(peerTrace, analyst)).toBe(true); + expect(canReadTraceResource(peerTrace, context('viewer'))).toBe(true); + expect(canDeleteTraceResource(peerTrace, analyst)).toBe(false); + expect(canDeleteTraceResource(peerTrace, admin)).toBe(true); + + expect(canReadTraceResource({ + ...peerTrace, + tenantId: 'tenant-b', + }, admin)).toBe(false); + }); +}); diff --git a/backend/src/services/rbac.ts b/backend/src/services/rbac.ts new file mode 100644 index 00000000..8294e691 --- /dev/null +++ b/backend/src/services/rbac.ts @@ -0,0 +1,130 @@ +// 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 { Response } from 'express'; +import type { RequestContext } from '../middleware/auth'; +import { + isOwnedByContext, + normalizeResourceOwner, + type ResourceOwnerFields, +} from './resourceOwnership'; + +export type RbacPermission = + | 'trace:read' + | 'trace:write' + | 'trace:download' + | 'trace:delete_own' + | 'trace:delete_any' + | 'agent:run' + | 'report:read' + | 'report:delete' + | 'provider:manage_workspace' + | 'provider:manage_org' + | 'audit:read'; + +const ROLE_PERMISSIONS: Record = { + viewer: ['trace:read', 'report:read'], + analyst: [ + 'trace:read', + 'trace:write', + 'trace:download', + 'trace:delete_own', + 'agent:run', + 'report:read', + ], + workspace_admin: [ + 'trace:read', + 'trace:write', + 'trace:download', + 'trace:delete_own', + 'trace:delete_any', + 'agent:run', + 'report:read', + 'report:delete', + 'provider:manage_workspace', + 'audit:read', + ], + org_admin: [ + 'trace:read', + 'trace:write', + 'trace:download', + 'trace:delete_own', + 'trace:delete_any', + 'agent:run', + 'report:read', + 'report:delete', + 'provider:manage_workspace', + 'provider:manage_org', + 'audit:read', + ], +}; + +const SCOPE_IMPLICATIONS: Partial> = { + 'trace:delete_own': ['trace:write', 'trace:delete'], + 'trace:delete_any': ['trace:delete:any'], + 'report:delete': ['report:write'], +}; + +export function hasRbacPermission(context: RequestContext, permission: RbacPermission): boolean { + if (context.scopes.includes('*')) return true; + if (context.scopes.includes(permission)) return true; + if (SCOPE_IMPLICATIONS[permission]?.some(scope => context.scopes.includes(scope))) return true; + return context.roles.some(role => ROLE_PERMISSIONS[role]?.includes(permission)); +} + +export function sharesWorkspaceWithContext( + resource: ResourceOwnerFields | null | undefined, + context: RequestContext, +): boolean { + const owner = normalizeResourceOwner(resource); + return owner.tenantId === context.tenantId && owner.workspaceId === context.workspaceId; +} + +export function canReadTraceResource( + resource: ResourceOwnerFields | null | undefined, + context: RequestContext, +): boolean { + return sharesWorkspaceWithContext(resource, context) && hasRbacPermission(context, 'trace:read'); +} + +export function canDownloadTraceResource( + resource: ResourceOwnerFields | null | undefined, + context: RequestContext, +): boolean { + return sharesWorkspaceWithContext(resource, context) + && (hasRbacPermission(context, 'trace:download') || hasRbacPermission(context, 'trace:read')); +} + +export function canDeleteTraceResource( + resource: ResourceOwnerFields | null | undefined, + context: RequestContext, +): boolean { + if (!sharesWorkspaceWithContext(resource, context)) return false; + if (hasRbacPermission(context, 'trace:delete_any')) return true; + return isOwnedByContext(resource, context) && hasRbacPermission(context, 'trace:delete_own'); +} + +export function canReadReportResource( + resource: ResourceOwnerFields | null | undefined, + context: RequestContext, +): boolean { + return sharesWorkspaceWithContext(resource, context) && hasRbacPermission(context, 'report:read'); +} + +export function canDeleteReportResource( + resource: ResourceOwnerFields | null | undefined, + context: RequestContext, +): boolean { + if (!sharesWorkspaceWithContext(resource, context)) return false; + if (hasRbacPermission(context, 'report:delete')) return true; + return isOwnedByContext(resource, context) && hasRbacPermission(context, 'report:delete'); +} + +export function sendForbidden(res: Response, details = 'Forbidden'): Response { + return res.status(403).json({ + success: false, + error: 'Forbidden', + details, + }); +} diff --git a/backend/src/services/traceMetadataStore.ts b/backend/src/services/traceMetadataStore.ts index bd1d0aa5..69995489 100644 --- a/backend/src/services/traceMetadataStore.ts +++ b/backend/src/services/traceMetadataStore.ts @@ -6,10 +6,10 @@ import path from 'path'; import fs from 'fs/promises'; import type { RequestContext } from '../middleware/auth'; import { - isOwnedByContext, ownerFieldsFromContext, type ResourceOwnerFields, } from './resourceOwnership'; +import { canReadTraceResource } from './rbac'; export interface TraceMetadata extends ResourceOwnerFields { id: string; @@ -101,7 +101,7 @@ export function isTraceMetadataOwnedByContext( metadata: TraceMetadata | null | undefined, context: RequestContext, ): metadata is TraceMetadata { - return Boolean(metadata && isOwnedByContext(metadata, context)); + return Boolean(metadata && canReadTraceResource(metadata, context)); } export async function readTraceMetadataForContext( diff --git a/docs/features/enterprise-multi-tenant/README.md b/docs/features/enterprise-multi-tenant/README.md index d2078801..5fa3f991 100644 --- a/docs/features/enterprise-multi-tenant/README.md +++ b/docs/features/enterprise-multi-tenant/README.md @@ -32,7 +32,7 @@ - [x] 2.1 `RequestContext` 接口与解析 middleware(SSO / API key / dev 三模式) - [x] 2.2 OIDC SSO 集成 + Onboarding flow(§15 全流程,含 audit) - [x] 2.3 API key 管理(创建 / 撤销 / scope / 过期 / 审计) -- [ ] 2.4 Membership / Role / RBAC 权限矩阵(§8.2)+ owner guard 全 route 覆盖 +- [x] 2.4 Membership / Role / RBAC 权限矩阵(§8.2)+ owner guard 全 route 覆盖 - [ ] 2.5 旧 API 兼容 wrapper:返回 `Deprecation: true` + `Sunset` header;统一走 RequestContext - [ ] 2.6 Resource-oriented API 切到 `/api/workspaces/:workspaceId/*`(§8.3) - [ ] 2.7 前端 workspace selection UI + workspace/window 上下文持久化分层(§9.2 表格)